<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrew Stevens</title>
    <description>The latest articles on DEV Community by Andrew Stevens (@andrew_stevens_3881478774).</description>
    <link>https://dev.to/andrew_stevens_3881478774</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4009561%2F438d0c07-2264-4309-9b6a-f73749a03ac1.jpg</url>
      <title>DEV Community: Andrew Stevens</title>
      <link>https://dev.to/andrew_stevens_3881478774</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andrew_stevens_3881478774"/>
    <language>en</language>
    <item>
      <title>Mapping the Agent Before Anyone Attacks It</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Wed, 14 Oct 2026 07:20:44 +0000</pubDate>
      <link>https://dev.to/sakurasky/mapping-the-agent-before-anyone-attacks-it-2ob8</link>
      <guid>https://dev.to/sakurasky/mapping-the-agent-before-anyone-attacks-it-2ob8</guid>
      <description>&lt;p&gt;An agent that reads tickets, queries BigQuery, and sends email usually gets reviewed one tool at a time, and each tool has an owner and an access review. What often has no owner is the chain between them: text a customer writes in a ticket can steer what the agent queries and where it sends the result. Much of the agent-specific risk sits in that chain.&lt;/p&gt;

&lt;p&gt;This series takes one working agent from threat model to release. Each part picks one way to attack it, shows the attack in a generic form, and then adds controls, along with what each one costs and where it stops helping. Our &lt;a href="https://www.sakurasky.com/blog/missing-primitives-for-trustworthy-ai/" rel="noopener noreferrer"&gt;Missing Primitives series&lt;/a&gt; covered the building blocks of trustworthy agents in general. Here I apply them, plus the platform features that have shipped since, to one system you could run.&lt;/p&gt;

&lt;p&gt;This first part describes the agent, maps where data and instructions get in, lines the threats up against current OWASP and MITRE ATLAS material, and adds a first control: a policy check on every tool call the agent makes through ADK.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent this series attacks
&lt;/h2&gt;

&lt;p&gt;The reference agent is an internal operations agent, a composite of a setup we often see in client work: Microsoft Entra ID for workforce identity, Google Cloud for data and agents. An employee signs in through Entra and uses the agent for three kinds of task:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Triage a ticket:&lt;/strong&gt; read it, search internal documents for the relevant runbook, and add an internal comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer a data question:&lt;/strong&gt; query BigQuery and email the answer to colleagues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply to a ticket:&lt;/strong&gt; read a customer's ticket, look up the facts in BigQuery, and send the customer a reply.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent is built with Google's Agent Development Kit (ADK) and runs on Agent Runtime (formerly Agent Engine), part of Gemini Enterprise Agent Platform (Google Cloud, n.d.c). Teams also run ADK agents on Cloud Run, and where that changes the answer, a short Cloud Run note says so. The agent reaches its tools over the Model Context Protocol (MCP):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BigQuery, through Google's remote MCP server at &lt;code&gt;bigquery.googleapis.com/mcp&lt;/code&gt;. As of October 2026 its documentation names six tools, and "[t]he only MCP tool that isn't read-only is &lt;code&gt;execute_sql&lt;/code&gt;". The agent is meant to use &lt;code&gt;execute_sql_readonly&lt;/code&gt;, which doesn't support DML, DDL, or Python UDFs (Google Cloud, n.d.o).&lt;/li&gt;
&lt;li&gt;Tickets, through an MCP server that the ticketing vendor operates.&lt;/li&gt;
&lt;li&gt;Documents, through an MCP server the company runs in front of its document store.&lt;/li&gt;
&lt;li&gt;Email, through an MCP server the company runs in front of its mail relay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping the agent off &lt;code&gt;execute_sql&lt;/code&gt; is an IAM job, since the in-process controls later in this post only govern a misled model; the checks at the end of the post cover it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-13-agent-security-reference-agent.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-13-agent-security-reference-agent.png" title="Figure 1. The reference agent and where text enters it." alt="Figure 1. The reference agent. An employee signed in through Microsoft Entra ID sends requests to a company backend, which validates the Entra token and sets the user and task before calling the operations agent on Agent Runtime. The agent exchanges context with a separate model endpoint and keeps its session state in Agent Platform Sessions. Its tool calls go through the ADK tool pipeline, where the policy check runs before each call, to four MCP servers: BigQuery (Google-operated), email and documents (company-operated), and ticketing (vendor-operated). People outside the company write untrusted text into the document store and the ticketing SaaS, and into customer-written fields in BigQuery, and mail from the relay reaches them." width="800" height="1481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Figure 1 numbers the flows. The tool results in flow 4 are where the design gets harder, because text written outside the company comes back to the agent inside them, while flow 5 sends mail to the same people who can write that text. The company backend matters as much as the runtime: it is the only caller the agent should accept, and it decides which employee and which task each session belongs to.&lt;/p&gt;

&lt;p&gt;Two identity questions sit under every arrow in Figure 1: how an employee's Entra sign-in reaches the agent and binds to the session, which Part 2 covers, and whose authority the agent uses at each MCP server, which Part 3 covers. Google's Agent Identity gives each agent its own SPIFFE-based identity and can hold tokens that users delegate to it (Google Cloud, n.d.b). Until Part 3 shows how those tokens are handled, I treat code in the agent's process as able to use any credential the agent holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is worth protecting
&lt;/h2&gt;

&lt;p&gt;An agent's assets go beyond the data behind its tools. For this one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The data behind the tools: BigQuery tables, internal documents, and ticket history, including customer details.&lt;/li&gt;
&lt;li&gt;Credentials: tokens for each MCP server, refresh tokens held on the employee's behalf (by the agent or a credential broker), and wherever delegated tokens are stored for each user.&lt;/li&gt;
&lt;li&gt;The ability to act: sending mail from a company domain and writing to tickets both carry the company's name, even when the content is wrong.&lt;/li&gt;
&lt;li&gt;Session state and memory: what the agent carries from one turn or one session to the next.&lt;/li&gt;
&lt;li&gt;The record of what happened: tool-call logs and the decisions around them, which incident response depends on.&lt;/li&gt;
&lt;li&gt;Spend: model tokens and BigQuery bytes scanned, which a looping agent can burn through.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where instructions get in
&lt;/h2&gt;

&lt;p&gt;Web applications can keep code and data on separate paths, as parameterised queries do, and many injection defences rely on that separation. A language model gets its instructions and its data in the same context window, as text. Anything that reaches the context can be read by the model as a request, whatever channel it arrived through.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entry point&lt;/th&gt;
&lt;th&gt;Who controls the text&lt;/th&gt;
&lt;th&gt;How it reaches the model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;System instructions&lt;/td&gt;
&lt;td&gt;The team that deploys the agent&lt;/td&gt;
&lt;td&gt;Loaded at startup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The employee's request&lt;/td&gt;
&lt;td&gt;The employee&lt;/td&gt;
&lt;td&gt;The chat turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool names and descriptions&lt;/td&gt;
&lt;td&gt;Whoever operates each MCP server&lt;/td&gt;
&lt;td&gt;Listed to the model so it can choose tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ticket bodies and comments&lt;/td&gt;
&lt;td&gt;Anyone who can file or comment on a ticket, often outside the company&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_ticket&lt;/code&gt; results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documents&lt;/td&gt;
&lt;td&gt;Anyone who can write or share into the store&lt;/td&gt;
&lt;td&gt;Search results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BigQuery rows&lt;/td&gt;
&lt;td&gt;Whatever wrote the rows, including free-text fields that customers filled in&lt;/td&gt;
&lt;td&gt;Query results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory and session state&lt;/td&gt;
&lt;td&gt;Earlier turns and earlier sessions&lt;/td&gt;
&lt;td&gt;Loaded with the session&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Table 1. Where text enters the reference agent's context.&lt;/p&gt;

&lt;p&gt;Only the first two rows are written by people the agent works for. The third is written by whoever runs each server: Google, the ticketing vendor, and the company's own teams. The next three are written by anyone who can get text into a ticket, a document, or a table, and the last by earlier sessions, which may themselves have read any of those. The BigQuery row is easy to miss: a table that stores ticket text or customer notes carries untrusted content even though the tool reading it is internal.&lt;/p&gt;

&lt;p&gt;The employee's own request isn't always benign either. The confirmations later in this post are approved by the employee who asked, so they help against a misled model and do little against an employee who wants data out. Where that matters, external sends of query results need a second approver or the company's existing data loss prevention controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three properties in one agent
&lt;/h2&gt;

&lt;p&gt;Simon Willison names the combination that turns injected text into data loss: access to private data, exposure to untrusted content, and the ability to communicate externally, which he calls the lethal trifecta (Willison, 2025). An agent with all three can be steered by text it reads into sending what it can read to wherever the text says.&lt;/p&gt;

&lt;p&gt;The reference agent has all three, and the ticket reply task uses all three in one session. It reads a ticket (untrusted content), queries BigQuery (private data), and sends email to a customer (external communication). It is often the task teams most want to automate, which is why this series attacks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One path, end to end
&lt;/h2&gt;

&lt;p&gt;Figure 2 shows the path in its generic form. It needs no software vulnerability, and each step is a call the agent is allowed to make.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-11-agent-security-ticket-to-email-path.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-11-agent-security-ticket-to-email-path.png" title="Figure 2. From a ticket to an outbound email." alt="Figure 2. One attack path through the reference agent. Someone outside the company files a ticket whose text includes instructions for an agent. An employee asks the agent to reply to that ticket. get_ticket puts the ticket text into the model's context. The model treats the embedded text as a task and queries a customer table. execute_sql_readonly returns rows the call's credentials can read. The model calls send_email to the customer's external address. Without a policy check, the rows leave in what looks like a normal reply; with the policy check, the call is denied because the recipient is external and the session has read untrusted content and sensitive data." width="800" height="1381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ticket text asks, in ordinary language, for some extra context to be included in the reply. The model has been told to be helpful with tickets, the request reads like part of the job, and the query and the email are both tools it was given. Whether a given model follows the embedded text can't be assumed either way, and that is part of the problem. A few test runs without a leak don't show the path is closed.&lt;/p&gt;

&lt;p&gt;Each component in Figure 2 behaves as designed. The ticketing system stores what customers write, the BigQuery MCP server runs a read-only query the credentials allow, and the email server sends mail. The leak comes from the order of the calls, so this post starts with a control that can see that order. It also comes to a simple recommendation for the ticket reply task: let the agent draft the reply and have the employee send it.&lt;/p&gt;

&lt;p&gt;If the rows in Figure 2 hold customers' personal data, the outcome is a data breach, whatever the AI Act says about the agent. In the EU, GDPR Article 33(1) requires the controller to notify the supervisory authority "without undue delay and, where feasible, not later than 72 hours after having become aware of it", unless the breach is unlikely to result in a risk to people's rights and freedoms (European Union, 2016). In the US, every state has a breach notification law (National Conference of State Legislatures, 2022), and a financial firm regulated in New York has to notify the Department of Financial Services "no later than 72 hours after determining that a cybersecurity incident has occurred" (New York State Department of Financial Services, n.d.). An EU financial entity also reports major ICT-related incidents to its competent authority under DORA (European Union, 2022). Which of these applies depends on the data and the firm, so check with counsel, but I'd put the agent on the same breach runbook as any other system that holds customer data before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  A first control: a policy check on each tool call
&lt;/h2&gt;

&lt;p&gt;The first control sits where the sequence is visible: between the model proposing a tool call and the tool running. ADK provides the hook. A &lt;code&gt;before_tool_callback&lt;/code&gt; is "[c]alled just before a specific tool's &lt;code&gt;run_async&lt;/code&gt; method is invoked, after the LLM has generated a function call for it"; returning &lt;code&gt;None&lt;/code&gt; lets the tool run, and returning a dictionary skips it (Google, n.d.b). ATLAS describes the same idea as mitigation AML.M0030, Restrict AI Agent Tool Invocation on Untrusted Data, with human approval as AML.M0029 (MITRE, 2026).&lt;/p&gt;

&lt;p&gt;The policy check is deny by default and works from three inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The task, fixed by the application when it creates the session, from the entry point the employee used. The model doesn't choose it, so text in a ticket shouldn't be able to promote a triage session into a reply session, provided nothing else can write that state (see Session state and credentials below).&lt;/li&gt;
&lt;li&gt;The tool's effect class: reads untrusted content, reads sensitive data, writes internally, or sends.&lt;/li&gt;
&lt;li&gt;What the session has already been exposed to: whether it has read untrusted content and whether it has read sensitive data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From those it decides allow, confirm, or deny, checking the rows of Table 2 in order.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The tool is unknown, or isn't in the task's allowlist&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An email with no recipients, recipient fields that aren't plain lists of addresses, an argument the policy check doesn't list, a subject or body that isn't text, or control or formatting characters in the subject&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An email to any external address after the session has read untrusted content and sensitive data&lt;/td&gt;
&lt;td&gt;Deny (the send in Figure 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any other email to an external address&lt;/td&gt;
&lt;td&gt;Confirm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An internal email or a ticket comment after the session has read untrusted content&lt;/td&gt;
&lt;td&gt;Confirm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything else in the task&lt;/td&gt;
&lt;td&gt;Allow, and record whether the call reads untrusted content or sensitive data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Table 2. How the policy check decides.&lt;/p&gt;

&lt;p&gt;An address with anything unusual in it, including a plus tag or non-ASCII characters, counts as external. Recipient fields are denied rather than parsed when they aren't plain lists, an unexpected argument could carry an address or data, and control or bidirectional formatting characters in a subject could add mail headers or disguise what the approver sees. The policy check doesn't inspect what the body says.&lt;/p&gt;

&lt;p&gt;Listing 1 keeps &lt;code&gt;send_email&lt;/code&gt; in the ticket reply task so the deny is visible. For most teams a drafting tool is the better fit, as the section on costs explains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Deny-by-default policy check for the reference agent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s tool calls.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;READ_UNTRUSTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_untrusted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# content anyone can write: tickets, documents
&lt;/span&gt;    &lt;span class="n"&gt;READ_SENSITIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_sensitive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# internal data: BigQuery
&lt;/span&gt;    &lt;span class="n"&gt;WRITE_INTERNAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write_internal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# changes inside the company: ticket comments
&lt;/span&gt;    &lt;span class="n"&gt;SEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                      &lt;span class="c1"&gt;# leaves the agent: email
&lt;/span&gt;
&lt;span class="n"&gt;TOOLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;READ_UNTRUSTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_documents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;READ_UNTRUSTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execute_sql_readonly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;READ_SENSITIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;add_ticket_comment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WRITE_INTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SEND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# The application sets the task from the entry point the employee used.
# The model never chooses it.
&lt;/span&gt;&lt;span class="n"&gt;TASKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;triage_ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_documents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;add_ticket_comment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data_question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execute_sql_readonly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket_reply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execute_sql_readonly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# A strict local part: anything unusual counts as external, including a
# plus tag, which can route mail into a ticket or a list.
&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[a-z0-9._-]+@example\.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# The email tool's arguments. Any other key could carry an address or data.
&lt;/span&gt;&lt;span class="n"&gt;SEND_FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bcc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;untrusted_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;sensitive_read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;  &lt;span class="c1"&gt;# "allow", "confirm", or "deny"
&lt;/span&gt;    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_recipients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Every address field, or None if any field isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t a list of strings.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bcc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_unsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Control characters, line separators, and bidirectional formatting.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u2028\u2029\u200e\u200f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u202a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u202e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u2066&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u2069&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_external&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isascii&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;INTERNAL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown tool &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TASKS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; is not allowed for task &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SEND&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;SEND_FIELDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unexpected argument&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject and body must be text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_unsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;control or formatting character in subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;recipients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_recipients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recipients missing or malformed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_external&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;untrusted_seen&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sensitive_read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deny&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external send after untrusted content and sensitive data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confirm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external recipient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SEND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WRITE_INTERNAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;untrusted_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confirm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;side effect after untrusted content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Record what the session has now been exposed to.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;READ_UNTRUSTED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;untrusted_seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;READ_SENSITIVE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sensitive_read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;within task policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rust&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;//! Deny-by-default policy check for the reference agent's tool calls.&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Debug)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ReadUntrusted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// content anyone can write: tickets, documents&lt;/span&gt;
    &lt;span class="n"&gt;ReadSensitive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// internal data: BigQuery&lt;/span&gt;
    &lt;span class="n"&gt;WriteInternal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// changes inside the company: ticket comments&lt;/span&gt;
    &lt;span class="nb"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// leaves the agent: email&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;effect_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Effect&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"get_ticket"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"search_documents"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ReadUntrusted&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"execute_sql_readonly"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ReadSensitive&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"add_ticket_comment"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;WriteInternal&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"send_email"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The application sets the task from the entry point the employee used.&lt;/span&gt;
&lt;span class="c1"&gt;// The model never chooses it.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;task_allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"triage_ticket"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"get_ticket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"search_documents"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"add_ticket_comment"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s"&gt;"data_question"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"execute_sql_readonly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"send_email"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s"&gt;"ticket_reply"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"get_ticket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"execute_sql_readonly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"send_email"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Default,&lt;/span&gt; &lt;span class="nd"&gt;Debug)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;untrusted_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;sensitive_read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Debug)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;Confirm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Every address field, or None if any field isn't a list of strings.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bcc"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// The email tool's arguments. Any other key could carry an address or data.&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SEND_FIELDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bcc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="cd"&gt;/// Control characters, line separators, and bidirectional formatting.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;unsafe_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="nf"&gt;.is_control&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'\u{2028}'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'\u{2029}'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'\u{200e}'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'\u{200f}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\u{202a}'&lt;/span&gt;&lt;span class="o"&gt;..=&lt;/span&gt;&lt;span class="sc"&gt;'\u{202e}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'\u{2066}'&lt;/span&gt;&lt;span class="o"&gt;..=&lt;/span&gt;&lt;span class="sc"&gt;'\u{2069}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// A strict local part: anything unusual counts as external, including a&lt;/span&gt;
&lt;span class="cd"&gt;/// plus tag, which can route mail into a ticket or a list.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="nf"&gt;.is_ascii&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="nf"&gt;.to_ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="nf"&gt;.strip_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="nf"&gt;.chars&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.all&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="nf"&gt;.is_ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="nf"&gt;.is_ascii_digit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="s"&gt;"._-"&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;effect_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unknown tool {tool}"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;task_allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{tool} is not allowed for task {task}"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;known&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;
            &lt;span class="nf"&gt;.as_object&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.is_some_and&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="nf"&gt;.keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.all&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SEND_FIELDS&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;())));&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;known&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unexpected argument"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.is_some_and&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.is_string&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"subject and body must be text"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;as_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="nf"&gt;.chars&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unsafe_char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"control or formatting character in subject"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"recipients missing or malformed"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;is_internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.untrusted_seen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.sensitive_read&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"external send after untrusted content and sensitive data"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Confirm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"external recipient"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;WriteInternal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.untrusted_seen&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Confirm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"side effect after untrusted content"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Record what the session has now been exposed to.&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ReadUntrusted&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.untrusted_seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ReadSensitive&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.sensitive_read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nn"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Allow&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 1. A deny-by-default policy check for the reference agent's tool calls. Tool names follow the MCP servers in Figure 1; example.com stands in for the company's domain.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Listing 2 wires the policy check into the agent as an ADK plugin. We read the ADK for Python 2.11.0 source for this, and our tests exercise the ordering against agent callbacks. Plugin callbacks registered on the app run before any agent's own &lt;code&gt;before_tool_callback&lt;/code&gt;, and they cover the agents the app drives, unless a sub-agent is wrapped in an &lt;code&gt;AgentTool&lt;/code&gt; with plugins excluded. They stop at the first plugin that returns a result, so the policy check goes first in the plugin list. Listing 2 is Python only, since it is wiring for ADK's Python SDK.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The policy check as an ADK plugin, registered first on the App.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.plugins.base_plugin&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BasePlugin&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;policy_check&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decide&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PolicyCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BasePlugin&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raise_approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_check&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raise_approval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raise_approval&lt;/span&gt;  &lt;span class="c1"&gt;# the application's approval queue
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;before_tool_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;untrusted_seen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_untrusted_seen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;sensitive_read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_sensitive_read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# The flags only ever go from False to True.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;untrusted_seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_untrusted_seen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sensitive_read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_sensitive_read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# ADK runs the tool
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confirm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# The application records the employee's decision, and the runtime
&lt;/span&gt;            &lt;span class="c1"&gt;# releases the call after the policy check runs again. Nothing the
&lt;/span&gt;            &lt;span class="c1"&gt;# model writes counts as approval.
&lt;/span&gt;            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;tool_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skip_summarization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;  &lt;span class="c1"&gt;# end the turn here
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awaiting_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_permitted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# the reason stays in the log
&lt;/span&gt;
&lt;span class="c1"&gt;# app = App(name="ops", root_agent=agent, plugins=[PolicyCheck(...), *other_plugins])
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 2. The policy check as an ADK plugin. Returning None runs the tool; returning a dictionary skips it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The backend sets the task in session state, and the policy check keeps its exposure flags there under keys of its own. The flags are set when a read is allowed, before it runs, so later calls in the same response see them.&lt;/p&gt;

&lt;h3&gt;
  
  
  When a call needs confirmation
&lt;/h3&gt;

&lt;p&gt;"Confirm" means the call doesn't run. The application raises an approval request instead and shows the signed-in employee the real server, tool name, and arguments. The approval is bound to that employee, the session, the task, the server and tool, and a hash of the canonicalised arguments, and it expires and can be used once.&lt;/p&gt;

&lt;p&gt;For external sends, asking the employee to sign in again helps confirm that the approver is the employee. In Entra, one option is a Conditional Access authentication context whose policy sets sign-in frequency to every time, requested with a claims challenge for each approval. Microsoft notes that "every time" allows five minutes of clock skew (Microsoft, n.d.b). The API opts in to the optional &lt;code&gt;acrs&lt;/code&gt; claim, and the backend checks it along with when the user last authenticated, from the optional &lt;code&gt;auth_time&lt;/code&gt; claim (Microsoft, n.d.a; Microsoft, 2026b). A token carrying &lt;code&gt;acrs&lt;/code&gt; only shows that the policy was met when the token was issued, and Microsoft's pages don't describe this combination end to end, so test it before relying on it. Rate-limiting approval requests and denied calls per session keeps a looping model from flooding the employee.&lt;/p&gt;

&lt;p&gt;The plugin also ends the model's turn when it raises a request, so a looping or misled model gets no further model steps in that turn. Other tool calls it proposed in the same response are each still checked, and each can raise its own request. In ADK 2.11.0 they are checked in the order the model listed them, as long as the policy check updates its flags before awaiting anything, as Listing 2 does. Order matters: a send listed before a query in the same response gets a confirm, while the reverse gets a deny. Running the policy check again at release is meant to close that gap, and Part 4 tests it. Our tests cover the read-then-send order, so rerun them on every ADK upgrade.&lt;/p&gt;

&lt;p&gt;Once the employee approves, the backend resumes the session with a reference to the approval, which lives in a record outside session state. Runtime code outside the model loop then releases the call under the credential the agent would have used. That code runs &lt;code&gt;decide&lt;/code&gt; again against the session's current flags and goes ahead only if the result is still confirm. It then calls the tool directly; sending the call back through the ADK pipeline would only raise a fresh request. Because the direct call skips ADK's after-tool callbacks, the release code also records the result in the session as a new event that references the approval, logs it, and runs any after-tool checks itself, such as the Model Armor scan described later in this post.&lt;/p&gt;

&lt;p&gt;Where the call runs under the agent's own identity, the release should also check that the approving employee is entitled to the data and the action; Part 3 compares that with running under the employee's delegated authority.&lt;/p&gt;

&lt;p&gt;ADK has a built-in tool confirmation feature. At the time of writing, its documentation marks it experimental, lists &lt;code&gt;VertexAiSessionService&lt;/code&gt; among the session services it doesn't support (alongside &lt;code&gt;DatabaseSessionService&lt;/code&gt;), and doesn't describe binding the confirmation to the signed-in user (Google, n.d.a). Check this against the ADK version you run. &lt;code&gt;VertexAiSessionService&lt;/code&gt; is the ADK client for Agent Platform Sessions, which are accessed through an Agent Runtime instance (Google Cloud, n.d.h). The reference agent keeps its sessions in Agent Platform Sessions, so the built-in feature is out for now. Part 4 builds approvals into the policy check and the company backend instead.&lt;/p&gt;

&lt;p&gt;We tested both listings. The release path is only described here; Part 4 builds and tests it. The Python tests run the policy check on its own and as a plugin inside an ADK 2.11.0 Runner with a scripted model, where the blocked send never reaches the tool, a confirm ends the model's turn, and the plugin denies a call before the agent's own callback runs. The Rust tests cover the same decision cases: a lookalike domain, addresses hidden in &lt;code&gt;bcc&lt;/code&gt;, a recipient field passed as one comma-separated string, an unexpected &lt;code&gt;reply_to&lt;/code&gt; argument, a subject or body passed as a list or object, a subject with an injected line break or a bidirectional override, a plus-tagged address, and local parts with percent signs, bang paths, encoded words, invisible characters, or non-ASCII letters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the policy check costs
&lt;/h2&gt;

&lt;p&gt;The first cost lands on the ticket reply task. With the policy check in place, the agent can read a customer's ticket, look up the facts, and draft a reply, but it can't send that reply to the customer once both flags are set. The simplest design moves the send out of the agent: the agent drafts, and the employee sends from the ticketing system after reading the draft. For most teams, that is where I'd start. The draft can still carry the data the injected text asked for, so the review screen should show which queries fed the draft and highlight values that came from them; Part 6 covers checking outbound content. Teams that want the agent to send directly need finer-grained tracking than two session flags, which Part 4 covers.&lt;/p&gt;

&lt;p&gt;The second cost is confirmation load. Session-level flags are coarse: one ticket read early in a conversation marks everything after it, and in the triage task every comment follows a &lt;code&gt;get_ticket&lt;/code&gt;, so the task's main action always needs confirmation. If employees see a confirmation on most actions, they tend to start approving without reading. In our experience, narrower tasks and shorter sessions do more to reduce that load than prompt changes.&lt;/p&gt;

&lt;p&gt;The third is upkeep. Every new tool needs an effect class and a place in each task. A tool that is added to an MCP server but not to the policy check is denied, so the table needs an owner who updates it whenever a server adds or changes tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the policy check stops helping
&lt;/h2&gt;

&lt;p&gt;The policy check sees tool names, arguments, and session history, and nothing else. It closes the exact path in Figure 2, for that task, with those flags. The rest of this section is what it leaves open, and, where a later part picks a gap up, which one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Routes data can still take
&lt;/h3&gt;

&lt;p&gt;A deny stops one call. The model sees &lt;code&gt;not_permitted&lt;/code&gt; and can try another route, such as an internal address that reaches the customer, which then arrives as a routine confirmation. Ending the session after a deny like the one in Figure 2, or denying every later side effect in it, closes that route within the session, and Part 4 adds it. That particular deny should rarely fire in normal use, so I'd alert on it from day one; Part 8 builds the detection around it.&lt;/p&gt;

&lt;p&gt;An address on the company's domain can still reach outsiders: a ticketing system's intake address that posts mail to a customer-visible ticket, a group with external guests, or a mailbox that forwards. A colleague may also not be entitled to the rows the agent read. Resolving recipients against the directory and checking their own access belongs in Parts 3 and 6.&lt;/p&gt;

&lt;p&gt;Another customer's ticket is a route too. In the ticket tasks, injected text can ask the agent to read a different customer's ticket and include it in the reply, which needs only a confirmation that looks routine. Binding the ticket ID when the session is created, and denying &lt;code&gt;get_ticket&lt;/code&gt; or &lt;code&gt;add_ticket_comment&lt;/code&gt; for any other ID, helps close that route.&lt;/p&gt;

&lt;p&gt;Some channels have an outside audience without looking like sends. A ticket comment is classed as an internal write, but on a ticket filed by a customer, the customer may be able to read it. A shared document link works the same way. Effect classes have to follow who can read the result, which Part 6 works through.&lt;/p&gt;

&lt;p&gt;Read tools can carry data out through their arguments. Reads are allowed even after sensitive data has been read, and a ticket ID or search query stuffed with query results reaches the vendor's server and its logs. Strict argument formats help, and so does treating calls to servers run by other organisations as sends once a session holds sensitive data.&lt;/p&gt;

&lt;p&gt;The agent's own output never reaches the policy check. Text the model returns to the employee, including links and images the chat interface renders, never passes through a tool call, and the policy check doesn't read email bodies, so HTML and remote images in them pass. Remote images and links in mail the agent sends internally can do the same when a colleague's mail client loads them. Part 6 covers output handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  What tool names and two flags can't capture
&lt;/h3&gt;

&lt;p&gt;Some sources are sensitive and untrusted at once. If a BigQuery table holds customer-written notes, a query result is both sensitive and untrusted, and the policy check as written only sets the sensitive flag. Internal documents are the mirror case: the policy check classes them as untrusted only, though they hold internal material. Tickets are mixed too, since &lt;code&gt;get_ticket&lt;/code&gt; returns the internal comments the triage task writes, so in the reply task it should set both flags, or the ticket server should return only customer-visible fields. Classifying by source rather than by tool helps with all three, though it needs a data catalogue to work from.&lt;/p&gt;

&lt;p&gt;Memory loaded into a new session and text an employee pastes from a customer email set no flag. The application should set both flags when it creates a session that loads memory, since memory can carry earlier query results as well as earlier untrusted text, and the untrusted flag when a session takes pasted text or attachments.&lt;/p&gt;

&lt;p&gt;The policy check allows &lt;code&gt;execute_sql_readonly&lt;/code&gt; in the right tasks; it doesn't judge whether a particular query reads more than the task needs. That depends on which identity the query runs as, and on row-level controls in BigQuery, which narrow results per employee when the query runs as that employee. Part 3 compares the options.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the policy check has to trust
&lt;/h3&gt;

&lt;p&gt;The policy check trusts the approval it receives. The approver can only judge what the application shows them, so the application should render the arguments itself (ASI09 in Table 3).&lt;/p&gt;

&lt;p&gt;The policy check also trusts that nothing changes a call after it allows it. Later plugins and the agent's own callbacks receive the same arguments and can change them after the policy check has allowed the call. In the ADK 2.11.0 source, built-in tools such as Google Search grounding are added to the model request rather than run by the agent's tool pipeline, so the policy check never sees them.&lt;/p&gt;

&lt;p&gt;The policy check trusts tool names. With several MCP servers, two can expose tools with the same name, and a server can change what a tool does after it was approved. Tools denied by the policy check also still have their descriptions listed to the model. ADK's &lt;code&gt;McpToolset&lt;/code&gt; accepts a &lt;code&gt;tool_filter&lt;/code&gt; and a &lt;code&gt;tool_name_prefix&lt;/code&gt;, which help in the meantime (the policy check's table then uses the prefixed names), and Part 5 pins tools to the authenticated server and the descriptions that were reviewed.&lt;/p&gt;

&lt;p&gt;An in-process plugin governs what a misled model can do, and a compromised runtime can skip it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Controls outside the process
&lt;/h3&gt;

&lt;p&gt;Agent Gateway, which Google describes as the key enforcement component of Agent Platform, sits outside the agent. Its overview says that by default all connections are blocked unless an explicit IAM policy grants access, and that for MCP traffic it can parse requests so policies can restrict specific tools (Google Cloud, n.d.a). Calls that violate its Access policies are blocked in enforce mode and only logged in dry-run mode (Google Cloud, n.d.e). A destination that isn't registered in Agent Registry needs an explicit access policy, and Principal Access Boundary policies can also be enforced on the agent's identity (Google Cloud, n.d.a; Google Cloud, n.d.e). In enforce mode, that narrows what a compromised runtime can reach to what the agent's identity is allowed, provided egress can't bypass the gateway. Within that, any delegated user tokens the runtime holds remain usable.&lt;/p&gt;

&lt;p&gt;Agent Gateway's egress policies are keyed on the agent's identity: "Agent principals are the members that you grant access to" (Google Cloud, n.d.e). The documentation we read doesn't show policy conditions on the end user, and as far as we can tell the gateway doesn't see session history, which is what the policy check works from. Google's pages also read differently on VPC Service Controls. The gateway overview lists enforcing perimeters for agent communications as a benefit (Google Cloud, n.d.a), and the release notes add that support from 9 September 2026 (Google Cloud, 2026a). It applies only to Agent Gateway deployments created after 8 September whose VPC connectivity uses the agent connectivity template in &lt;code&gt;ALL_TRAFFIC&lt;/code&gt; egress mode. The IAM Access policies page still carries a note that Access policies don't support VPC Service Controls (Google Cloud, n.d.e). Part 6 works out what that means for a perimeter around this agent.&lt;/p&gt;

&lt;p&gt;My read, while the documentation settles: treat Agent Gateway, and Model Armor below, as defence in depth, and treat the policy check as the control you own and can test today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud Run note.&lt;/strong&gt; As of October 2026, Agent Gateway's documentation lists Agent Runtime (ingress and egress) and Gemini Enterprise (egress only), and doesn't list Cloud Run, although Agent Identity lists Cloud Run (Google Cloud, n.d.b; Google Cloud, n.d.a). A Cloud Run deployment keeps the in-process policy check but needs its egress controls from elsewhere, which Part 6 covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Model Armor fits
&lt;/h2&gt;

&lt;p&gt;Filters that look for injected instructions are useful as a second layer. I wouldn't rely on one on its own, because it can let too much through. As of October 2026, Google's Model Armor can inspect &lt;code&gt;tools/call&lt;/code&gt; requests and responses for Google's MCP servers, but not &lt;code&gt;tools/list&lt;/code&gt;, and Google advises enabling its prompt injection and jailbreak filter only where MCP traffic carries natural language (Google Cloud, n.d.g). BigQuery is on the list of MCP servers it supports (Google Cloud, n.d.i). Google's BigQuery MCP page also warns that "When Model Armor is enabled with logging enabled, Model Armor logs the entire payload", so query results can end up in your logs (Google Cloud, n.d.o).&lt;/p&gt;

&lt;p&gt;In this design, that integration would see the BigQuery traffic but not the ticket text in Figure 2, which arrives through the vendor's server.&lt;/p&gt;

&lt;p&gt;On Agent Runtime, Model Armor can also be enabled on Agent Gateway. For egress MCP traffic to servers in general, Google's page says it sanitises &lt;code&gt;tools/call&lt;/code&gt; and &lt;code&gt;prompts/get&lt;/code&gt; requests and responses and tool execution errors, and it lists &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;resources/*&lt;/code&gt;, and "Streamable HTTP/SSE for MCP" among the payloads that pass without sanitization. Model Armor also has to be in the same region as the services it integrates with (Google Cloud, n.d.f). The page doesn't make clear whether a &lt;code&gt;tools/call&lt;/code&gt; response that a server streams over SSE is inspected, so check this against the vendor's server before counting on it. Where that doesn't apply, including on Cloud Run, the application can send &lt;code&gt;get_ticket&lt;/code&gt; results to Model Armor itself, as applications do for prompts and model responses (Google Cloud, n.d.k), for example from an after-tool callback.&lt;/p&gt;

&lt;p&gt;Willison's point about guardrail products applies: vendors often claim to catch "95% of attacks", but "in web application security 95% is very much a failing grade" (Willison, 2025). A detector can lower the rate of successful injections, and the policy check denies the send in Figure 2 whether or not the detector flags the ticket. Part 4 tests both against the same set of tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping the threats to OWASP and ATLAS
&lt;/h2&gt;

&lt;p&gt;I map against two catalogues, because they answer different questions. The OWASP GenAI Security Project published the Top 10 for Agentic Applications for 2026 in December 2025. It describes risk categories and mitigations, and refers to the 2025 edition of the OWASP LLM Top 10: ASI03, for example, is described as the agentic evolution of Excessive Agency (LLM06:2025) (OWASP GenAI Security Project, 2025). The project published a 2026 edition of the LLM Top 10 in August 2026 with renumbered entries, so Excessive Agency is now LLM03 (OWASP GenAI Security Project, 2026), and Table 3 uses the 2026 numbers. MITRE ATLAS describes adversary techniques and mitigations; the data release used here is 2026.09 (MITRE, 2026).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat to the reference agent&lt;/th&gt;
&lt;th&gt;OWASP Agentic 2026&lt;/th&gt;
&lt;th&gt;OWASP LLM 2026 (our mapping)&lt;/th&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ticket or document text redirects the agent&lt;/td&gt;
&lt;td&gt;ASI01 Agent Goal Hijack&lt;/td&gt;
&lt;td&gt;LLM01 Prompt Injection&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Allowed tools chained for an unintended purpose&lt;/td&gt;
&lt;td&gt;ASI02 Tool Misuse and Exploitation&lt;/td&gt;
&lt;td&gt;LLM03 Excessive Agency&lt;/td&gt;
&lt;td&gt;1, 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The agent's authority is wider than the user's or the task's&lt;/td&gt;
&lt;td&gt;ASI03 Identity and Privilege Abuse&lt;/td&gt;
&lt;td&gt;LLM03 Excessive Agency&lt;/td&gt;
&lt;td&gt;2, 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A caller poses as another employee, resumes someone else's session, or changes the task or flags in session state&lt;/td&gt;
&lt;td&gt;ASI03 Identity and Privilege Abuse&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A token is harvested through the agent or stolen, then replayed&lt;/td&gt;
&lt;td&gt;ASI03 Identity and Privilege Abuse&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A tool's description or behaviour is poisoned or changes&lt;/td&gt;
&lt;td&gt;ASI04 Agentic Supply Chain Vulnerabilities&lt;/td&gt;
&lt;td&gt;LLM04 Supply Chain&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data leaves through email, links, or rendered images&lt;/td&gt;
&lt;td&gt;ASI02 Tool Misuse and Exploitation&lt;/td&gt;
&lt;td&gt;LLM02 Sensitive Information Disclosure; LLM10 Improper Output Handling&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poisoned memory changes later sessions&lt;/td&gt;
&lt;td&gt;ASI06 Memory &amp;amp; Context Poisoning&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A misleading summary gets a human to approve an action&lt;/td&gt;
&lt;td&gt;ASI09 Human-Agent Trust Exploitation&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Looping, runaway cost, or an agent acting outside its purpose&lt;/td&gt;
&lt;td&gt;ASI02 Tool Misuse and Exploitation; ASI08 Cascading Failures; ASI10 Rogue Agents&lt;/td&gt;
&lt;td&gt;LLM06 Unbounded Consumption&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Table 3. The reference agent's threats mapped to OWASP, with the part of the series that covers each. The LLM 2026 column is our mapping; OWASP's agentic document lists its own related LLM entries, in 2025 numbering, for each ASI item in an appendix. Blank cells are where we didn't map an entry.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat to the reference agent&lt;/th&gt;
&lt;th&gt;MITRE ATLAS 2026.09&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ticket or document text redirects the agent&lt;/td&gt;
&lt;td&gt;AML.T0051.001 LLM Prompt Injection: Indirect; AML.T0099 AI Agent Tool Data Poisoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Allowed tools chained for an unintended purpose&lt;/td&gt;
&lt;td&gt;AML.T0053 AI Agent Tool Invocation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The agent's authority is wider than the user's or the task's&lt;/td&gt;
&lt;td&gt;Mitigations AML.M0026, AML.M0027, AML.M0028, and AML.M0037&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A token is harvested through the agent or stolen, then replayed&lt;/td&gt;
&lt;td&gt;AML.T0091.000 Use Alternate Authentication Material: Application Access Token; AML.T0098 AI Agent Tool Credential Harvesting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A tool's description or behaviour is poisoned or changes&lt;/td&gt;
&lt;td&gt;AML.T0010.005 AI Supply Chain Compromise: AI Agent Tool; AML.T0110 AI Agent Tool Poisoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data leaves through email, links, or rendered images&lt;/td&gt;
&lt;td&gt;AML.T0086 Exfiltration via AI Agent Tool Invocation; AML.T0077 LLM Response Rendering; AML.T0057 LLM Data Leakage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poisoned memory changes later sessions&lt;/td&gt;
&lt;td&gt;AML.T0080.000 AI Agent Context Poisoning: Memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A misleading summary gets a human to approve an action&lt;/td&gt;
&lt;td&gt;AML.T0067 LLM Trusted Output Components Manipulation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Looping, runaway cost, or an agent acting outside its purpose&lt;/td&gt;
&lt;td&gt;AML.T0034.002 Cost Harvesting: Agentic Resource Consumption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Table 4. The same threats mapped to MITRE ATLAS. We didn't find an ATLAS technique that matches over-broad agent authority well, so that row names the ATLAS mitigations for agent and tool permissions instead, and we didn't map the session-tampering row.&lt;/p&gt;

&lt;p&gt;Two OWASP entries get less attention here. ASI07, Insecure Inter-Agent Communication, applies once agents call other agents, and this is a single agent. ASI05, Unexpected Code Execution, is narrow while the agent's only code-like capability is read-only SQL. A query can still call a remote function, though. BigQuery documents remote functions as running code in Cloud Run or Cloud Run functions through a connection, used "in a query just like any other user-defined functions" (Google Cloud, n.d.p). I haven't found Google documentation saying whether &lt;code&gt;execute_sql_readonly&lt;/code&gt; allows them, so the identity running the query shouldn't have access to routines, connections, or external data sources it doesn't need. I'd also test whether &lt;code&gt;execute_sql_readonly&lt;/code&gt; accepts &lt;code&gt;EXPORT DATA&lt;/code&gt; statements or the &lt;code&gt;EXTERNAL_QUERY&lt;/code&gt; function before relying on it.&lt;/p&gt;

&lt;p&gt;The mapping is also a useful artefact for governance work in the US and the EU. In NIST's AI Risk Management Framework, "The MAP function establishes the context to frame risks related to an AI system" (National Institute of Standards and Technology, 2023), and the Generative AI Profile lists information security, including direct and indirect prompt injection, among the risks it covers (National Institute of Standards and Technology, 2024).&lt;/p&gt;

&lt;p&gt;In the EU, many internal operations agents won't fall into a high-risk category under the AI Act. Where one does, Article 15(5) requires such systems to be "resilient against attempts by unauthorised third parties to alter their use, outputs or performance by exploiting system vulnerabilities" (European Commission, n.d.), and the company that built it may count as its provider. For the rest, Article 15 is still a useful bar. The Commission's page notes that Article 15 has been amended by the Digital Omnibus on AI, so check the current text and dates; our &lt;a href="https://www.sakurasky.com/blog/regulatory-stack-part-4/" rel="noopener noreferrer"&gt;Regulatory Stack post on Article 15&lt;/a&gt; covers that side.&lt;/p&gt;

&lt;p&gt;I'd keep the threat model as a versioned table beside the agent's code, so the engineers and the governance team work from the same file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch stages as of 13 October 2026
&lt;/h2&gt;

&lt;p&gt;Most of the platform features this post relies on shipped this year, so Table 5 records their launch stage as of the date we checked.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Launch stage&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent Identity&lt;/td&gt;
&lt;td&gt;Generally available since 22 April 2026&lt;/td&gt;
&lt;td&gt;Google Cloud, 2026c&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent Identity auth manager&lt;/td&gt;
&lt;td&gt;Generally available since 22 August 2026, after a preview from April&lt;/td&gt;
&lt;td&gt;Google Cloud, 2026c&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent Gateway&lt;/td&gt;
&lt;td&gt;Generally available since 18 June 2026; VPC Service Controls support announced on 9 September 2026 without a stated stage, for deployments created after 8 September that use the agent connectivity template in &lt;code&gt;ALL_TRAFFIC&lt;/code&gt; egress mode&lt;/td&gt;
&lt;td&gt;Google Cloud, 2026a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM Unified Access Policies (Access policies)&lt;/td&gt;
&lt;td&gt;Generally available since 31 August 2026&lt;/td&gt;
&lt;td&gt;Google Cloud, 2026a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model Armor for Agent Gateway&lt;/td&gt;
&lt;td&gt;Generally available since 24 June 2026&lt;/td&gt;
&lt;td&gt;Google Cloud, 2026a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google's remote MCP servers&lt;/td&gt;
&lt;td&gt;Generally available as a capability since 1 May 2026, with individual servers in Preview or GA&lt;/td&gt;
&lt;td&gt;Google Cloud, 2026b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BigQuery remote MCP server&lt;/td&gt;
&lt;td&gt;Not marked Preview in Google's list of supported MCP products, which the MCP release notes point to for launch status; no stage stated on the server's own page&lt;/td&gt;
&lt;td&gt;Google Cloud, 2026b; Google Cloud, n.d.m; Google Cloud, n.d.o&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model Armor's MCP server integration&lt;/td&gt;
&lt;td&gt;No stage stated on its page&lt;/td&gt;
&lt;td&gt;Google Cloud, n.d.g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADK tool confirmation&lt;/td&gt;
&lt;td&gt;Experimental&lt;/td&gt;
&lt;td&gt;Google, n.d.a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Table 5. Launch stages of the features this post relies on.&lt;/p&gt;

&lt;p&gt;Where no stage is stated, I'd confirm it with Google before relying on the feature in production, since pre-GA terms and support differ. The design doesn't depend on ADK's experimental confirmation feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check in your own deployment
&lt;/h2&gt;

&lt;p&gt;Most of what follows can't be settled in a post, because it depends on how your tenant, projects, and servers are configured. These are the checks I'd want to see pass, with evidence, before an agent like this goes near production. Four of them I'd treat as blocking a release: only the backend can call the agent, &lt;code&gt;user_id&lt;/code&gt; comes from the validated token, the policy check's keys can't be changed after the session is created, and a tool missing from the policy check's table is denied.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who can reach the agent
&lt;/h3&gt;

&lt;p&gt;Owner: usually the platform team that runs Google Cloud IAM.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only the company backend can call the agent, and only the backend and the agent's own identity hold permissions on its sessions. The backend gets a custom role with only &lt;code&gt;aiplatform.reasoningEngines.query&lt;/code&gt; on the agent resource, as Google's sharing page suggests (Google Cloud, n.d.l), and nothing else inherits that permission from the project, folder, or organisation through a role such as &lt;code&gt;roles/aiplatform.user&lt;/code&gt; or a basic role (on Cloud Run, the invoker role). Google notes that default ADK agents trust the frontend, "which gives the frontend full control of sessions and users" (Google Cloud, n.d.l).&lt;/li&gt;
&lt;li&gt;Deploy rights on the agent are treated as the agent's authority: anyone who can change its code or configuration acts as it.&lt;/li&gt;
&lt;li&gt;The backend keeps its own record of which session belongs to which user, and resumes a session only for the user it was created for. Anyone holding the query permission can call the agent's session operations directly, including creating sessions, so the IAM restriction above is what protects &lt;code&gt;policy_task&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How the backend checks the employee
&lt;/h3&gt;

&lt;p&gt;Owner: the backend's developers, with the identity team.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The backend validates the Entra token's signature, issuer, tenant, and lifetime (&lt;code&gt;exp&lt;/code&gt; and &lt;code&gt;nbf&lt;/code&gt;), and checks that the audience identifies its own API: in v2.0 tokens that is the client ID, and in v1.0 tokens the client ID or the resource URI the client requested (Microsoft, 2026a).&lt;/li&gt;
&lt;li&gt;It accepts only delegated user tokens that carry its scope in &lt;code&gt;scp&lt;/code&gt;, from client apps it expects (&lt;code&gt;azp&lt;/code&gt; in v2.0 tokens, &lt;code&gt;appid&lt;/code&gt; in v1.0), and rejects app-only tokens, for which the optional &lt;code&gt;idtyp&lt;/code&gt; claim is &lt;code&gt;app&lt;/code&gt; (Microsoft, 2026b).&lt;/li&gt;
&lt;li&gt;It rejects guest accounts unless a task is meant for them, for example by requiring user assignment on the API's enterprise application and assigning only the intended users or groups, and by checking the optional &lt;code&gt;acct&lt;/code&gt; claim, which is &lt;code&gt;1&lt;/code&gt; for a guest (Microsoft, 2026b). Both &lt;code&gt;idtyp&lt;/code&gt; and &lt;code&gt;acct&lt;/code&gt; have to be configured as optional claims on the API's app registration. Once configured, &lt;code&gt;idtyp&lt;/code&gt; is emitted on app-only tokens, which is all this check needs (Microsoft, 2026b).&lt;/li&gt;
&lt;li&gt;It derives the ADK &lt;code&gt;user_id&lt;/code&gt; from the tenant and object IDs (&lt;code&gt;tid&lt;/code&gt; and &lt;code&gt;oid&lt;/code&gt;; the client has to request the &lt;code&gt;profile&lt;/code&gt; scope for these to be issued), never from a client-supplied field (Microsoft, 2026a). Agent Runtime accepts a user ID the caller chooses (its documentation says "[c]hoose your own user ID") (Google Cloud, n.d.n), so this binding is the backend's job.&lt;/li&gt;
&lt;li&gt;The agent never sees the Entra token, so it takes the employee's identity entirely on the backend's word. If delegated tokens are held per user, a caller who can choose &lt;code&gt;user_id&lt;/code&gt; can use another employee's tokens, which makes the backend's identity and code as sensitive as the tokens they can unlock. Part 2 covers this binding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Session state and credentials
&lt;/h3&gt;

&lt;p&gt;Owner: the agent's developers, with the platform team.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The policy check's keys (&lt;code&gt;policy_task&lt;/code&gt; and the flags) can't be changed after the session is created. In practice that means:

&lt;ul&gt;
&lt;li&gt;no initial &lt;code&gt;state&lt;/code&gt; or &lt;code&gt;events&lt;/code&gt; from the client, and no &lt;code&gt;state_delta&lt;/code&gt; or extra session events on a query;&lt;/li&gt;
&lt;li&gt;no session PATCH through the ADK API server, and no session rewind outside the backend;&lt;/li&gt;
&lt;li&gt;no agent &lt;code&gt;output_key&lt;/code&gt;, tool, or callback that writes those keys, and no &lt;code&gt;user:&lt;/code&gt; or &lt;code&gt;app:&lt;/code&gt; prefix on them.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping the keys, and the approval records, in a store outside session state is stronger still.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Delegated tokens sit with the auth manager or another broker, not in session state that anything reading the session can see (ADK can keep them in session state, so this is a configuration choice), and each task gets its own toolsets, so credentials for servers outside the task aren't loaded.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tools, arguments, and approvals
&lt;/h3&gt;

&lt;p&gt;Owner: the agent's developers, with an application security review.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No plugin or callback that runs after the policy check modifies tool arguments, model-side built-in tools are off, and no MCP sampling or elicitation callbacks are registered on the toolsets, since server-initiated requests don't pass through the tool pipeline.&lt;/li&gt;
&lt;li&gt;Every tool exposed by every MCP server the agent can reach appears in the policy check's table, and a tool that doesn't is denied in the deployed agent, which a test confirms.&lt;/li&gt;
&lt;li&gt;The email tool's real argument names match &lt;code&gt;SEND_FIELDS&lt;/code&gt; in Listing 1. A &lt;code&gt;reply_to&lt;/code&gt;, attachment, or template field stays denied until the policy check knows how to check it, and the email server builds recipient headers only from &lt;code&gt;to&lt;/code&gt;, &lt;code&gt;cc&lt;/code&gt;, and &lt;code&gt;bcc&lt;/code&gt;, and normalises line endings in the body so it can't end the message and start another (SMTP smuggling).&lt;/li&gt;
&lt;li&gt;Approvals are bound as described above, single-use, shown by the application rather than summarised by the model, and checked again by the policy check at release.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where data can go
&lt;/h3&gt;

&lt;p&gt;Owner: the data owners and the teams that run mail and ticketing, with security.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The internal addresses that reach outsiders (intake addresses, groups with guests, and forwarding mailboxes) are listed and kept current.&lt;/li&gt;
&lt;li&gt;The BigQuery tables that hold text written outside the company are listed, so queries against them can be classed as reading untrusted content.&lt;/li&gt;
&lt;li&gt;Every principal that reaches BigQuery is denied read-write MCP tools and holds no BigQuery write permissions. Google documents a deny policy that limits its MCP servers to read-only tools; its example denies every principal in the project, folder, or organisation it is attached to. The denied tool still appears in the tool list, so a &lt;code&gt;tool_filter&lt;/code&gt; on the toolset keeps it away from the model (Google Cloud, n.d.j). A VPC Service Controls perimeter around the BigQuery project, which Google documents for its own MCP servers with conditions such as read-only tool calls (Google Cloud, n.d.d), helps keep whatever statements &lt;code&gt;execute_sql_readonly&lt;/code&gt; does allow from moving data outside it. Check how that perimeter and Agent Gateway's Access policies fit together, given the documentation notes above.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Platform support and logging
&lt;/h3&gt;

&lt;p&gt;Owner: the platform team, with security operations.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent Gateway, Model Armor, and the Agent Identity auth manager are confirmed to support the runtime, region, and MCP servers in use, at a launch stage you have accepted for production, checked against Table 5 and Google's current documentation.&lt;/li&gt;
&lt;li&gt;The policy check's decisions are logged with the session, task, tool, arguments, and outcome, with access and retention set as for the data the arguments carry. Part 8 builds on this.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this series leaves out
&lt;/h2&gt;

&lt;p&gt;Some common questions fall outside the reference agent. It is a single agent, so agent-to-agent delegation and multi-agent protocols are out of scope; our post on &lt;a href="https://www.sakurasky.com/blog/missing-primitives-for-trustworthy-ai-part-10/" rel="noopener noreferrer"&gt;secure multi-agent protocols&lt;/a&gt; covers that ground. It has no code-execution tool or sandbox. Cost and resource exhaustion appear only as far as Part 8 detects and stops a looping agent, and our post on &lt;a href="https://www.sakurasky.com/blog/missing-primitives-for-trustworthy-ai-part-12/" rel="noopener noreferrer"&gt;resource governance&lt;/a&gt; covers budgets and throttling. The model's own supply chain, meaning where its weights come from and how they were trained, is also out of scope, as are the web security of the chat front end, the model provider's data handling, and the ticketing vendor's own platform security.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the series
&lt;/h2&gt;

&lt;p&gt;Part 2 follows the employee's identity into the agent: from an Entra sign-in through the company backend, how the session is bound to that employee, and how the employee's Entra identity is federated into Google Cloud for the calls that need it. Part 3 follows the agent's calls out to its tools and compares the authority each can run under: delegated user authority, the agent's own identity, or a combination. It also covers the current MCP authorization specification (version 2026-07-28), under which servers accept only tokens issued for them and never pass a client's token on to upstream APIs (Model Context Protocol, 2026a; Model Context Protocol, 2026b), and where the servers in this design depart from it.&lt;/p&gt;

&lt;p&gt;Part 4 takes indirect injection through tickets, documents, and query results, with value-level tracking, the approval path, and a test of Model Armor and the policy check against the same tickets. Part 5 deals with MCP servers you didn't write, Part 6 with the ways data leaves, and Part 7 with memory. Part 8 is about detecting and containing an agent mid-task, and Part 9 is red-teaming before release.&lt;/p&gt;

&lt;p&gt;Which of your agents reads text from outside the company in the same session as it reads your data, and what can it send?&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;European Commission, n.d. &lt;em&gt;Article 15: Accuracy, robustness and cybersecurity.&lt;/em&gt; AI Act Service Desk. Available at: &lt;a href="https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-15" rel="noopener noreferrer"&gt;https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-15&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;European Union, 2016. &lt;em&gt;Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data, and repealing Directive 95/46/EC (General Data Protection Regulation).&lt;/em&gt; Article 33. Available at: &lt;a href="http://data.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;http://data.europa.eu/eli/reg/2016/679/oj&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;European Union, 2022. &lt;em&gt;Regulation (EU) 2022/2554 of the European Parliament and of the Council of 14 December 2022 on digital operational resilience for the financial sector.&lt;/em&gt; OJ L 333, 27.12.2022, Article 19. Available at: &lt;a href="http://data.europa.eu/eli/reg/2022/2554/oj" rel="noopener noreferrer"&gt;http://data.europa.eu/eli/reg/2022/2554/oj&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google, n.d.a. &lt;em&gt;Get action confirmation for ADK Tools.&lt;/em&gt; Agent Development Kit documentation. Available at: &lt;a href="https://adk.dev/tools-custom/confirmation/" rel="noopener noreferrer"&gt;https://adk.dev/tools-custom/confirmation/&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google, n.d.b. &lt;em&gt;Types of callbacks.&lt;/em&gt; Agent Development Kit documentation. Available at: &lt;a href="https://adk.dev/callbacks/types-of-callbacks/" rel="noopener noreferrer"&gt;https://adk.dev/callbacks/types-of-callbacks/&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, 2026a. &lt;em&gt;Gemini Enterprise Agent Platform release notes.&lt;/em&gt; Entries from 22 April to 9 September 2026. Available at: &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/release-notes" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/gemini-enterprise-agent-platform/release-notes&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, 2026b. &lt;em&gt;Google Cloud MCP servers release notes.&lt;/em&gt; Entry of 1 May 2026. Available at: &lt;a href="https://docs.cloud.google.com/mcp/release-notes" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/mcp/release-notes&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, 2026c. &lt;em&gt;IAM release notes.&lt;/em&gt; Entries of 22 April and 22 August 2026. Available at: &lt;a href="https://docs.cloud.google.com/iam/docs/release-notes" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/iam/docs/release-notes&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.a. &lt;em&gt;Agent Gateway overview.&lt;/em&gt; Gemini Enterprise Agent Platform documentation. Available at: &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/govern/gateways/agent-gateway-overview" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/gemini-enterprise-agent-platform/govern/gateways/agent-gateway-overview&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.b. &lt;em&gt;Agent Identity overview.&lt;/em&gt; Identity and Access Management documentation. Available at: &lt;a href="https://docs.cloud.google.com/iam/docs/agent-identity-overview" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/iam/docs/agent-identity-overview&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.c. &lt;em&gt;Agent Platform overview.&lt;/em&gt; Gemini Enterprise Agent Platform documentation. Available at: &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/overview" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/gemini-enterprise-agent-platform/overview&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.d. &lt;em&gt;Control MCP use in a VPC Service Controls perimeter.&lt;/em&gt; Google Cloud MCP servers documentation. Available at: &lt;a href="https://docs.cloud.google.com/mcp/control-mcp-use-vpc-sc-perimeter" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/mcp/control-mcp-use-vpc-sc-perimeter&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.e. &lt;em&gt;IAM Access policies overview.&lt;/em&gt; Gemini Enterprise Agent Platform documentation. Available at: &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/govern/policies/iam-overview-uap" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/gemini-enterprise-agent-platform/govern/policies/iam-overview-uap&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.f. &lt;em&gt;Integrate Model Armor with Agent Gateway.&lt;/em&gt; Model Armor documentation. Available at: &lt;a href="https://docs.cloud.google.com/model-armor/model-armor-agent-gateway-integration" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/model-armor/model-armor-agent-gateway-integration&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.g. &lt;em&gt;Integrate with Google and Google Cloud MCP servers.&lt;/em&gt; Model Armor documentation. Available at: &lt;a href="https://docs.cloud.google.com/model-armor/model-armor-mcp-google-cloud-integration" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/model-armor/model-armor-mcp-google-cloud-integration&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.h. &lt;em&gt;Manage sessions with Agent Development Kit.&lt;/em&gt; Gemini Enterprise Agent Platform documentation. Available at: &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/sessions/manage-with-adk" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/sessions/manage-with-adk&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.i. &lt;em&gt;Model Armor supported products.&lt;/em&gt; Google Cloud MCP servers documentation. Available at: &lt;a href="https://docs.cloud.google.com/mcp/model-armor-supported-products" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/mcp/model-armor-supported-products&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.j. &lt;em&gt;Prevent read-write MCP tool use.&lt;/em&gt; Google Cloud MCP servers documentation. Available at: &lt;a href="https://docs.cloud.google.com/mcp/prevent-read-write-tool-use" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/mcp/prevent-read-write-tool-use&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.k. &lt;em&gt;Protect AI applications with Model Armor.&lt;/em&gt; Security Command Center documentation. Available at: &lt;a href="https://docs.cloud.google.com/security-command-center/docs/model-armor" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/security-command-center/docs/model-armor&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.l. &lt;em&gt;Share an agent.&lt;/em&gt; Gemini Enterprise Agent Platform documentation. Available at: &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/govern/share-agent" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/gemini-enterprise-agent-platform/govern/share-agent&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.m. &lt;em&gt;Supported products.&lt;/em&gt; Google Cloud MCP servers documentation. Available at: &lt;a href="https://docs.cloud.google.com/mcp/supported-products" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/mcp/supported-products&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.n. &lt;em&gt;Use an Agent Development Kit agent.&lt;/em&gt; Gemini Enterprise Agent Platform documentation. Available at: &lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/runtime/use-an-adk-agent" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/runtime/use-an-adk-agent&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.o. &lt;em&gt;Use the BigQuery remote MCP server.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/use-bigquery-mcp" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/use-bigquery-mcp&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.p. &lt;em&gt;Work with remote functions.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/remote-functions" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/remote-functions&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026a. &lt;em&gt;Access token claims reference.&lt;/em&gt; Microsoft identity platform documentation, Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026b. &lt;em&gt;Optional claims reference.&lt;/em&gt; Microsoft identity platform documentation, Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, n.d.a. &lt;em&gt;Developer guide to Conditional Access authentication context.&lt;/em&gt; Microsoft identity platform documentation, Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/developer-guide-conditional-access-authentication-context" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/entra/identity-platform/developer-guide-conditional-access-authentication-context&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, n.d.b. &lt;em&gt;Conditional Access adaptive session lifetime policies.&lt;/em&gt; Microsoft Entra ID documentation, Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;MITRE, 2026. &lt;em&gt;ATLAS data, release 2026.09.&lt;/em&gt; MITRE ATLAS. Available at: &lt;a href="https://github.com/mitre-atlas/atlas-data/blob/main/dist/v6/ATLAS-2026.09.yaml" rel="noopener noreferrer"&gt;https://github.com/mitre-atlas/atlas-data/blob/main/dist/v6/ATLAS-2026.09.yaml&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Model Context Protocol, 2026a. &lt;em&gt;Authorization.&lt;/em&gt; Specification, version 2026-07-28. Available at: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Model Context Protocol, 2026b. &lt;em&gt;Authorization security considerations.&lt;/em&gt; Specification, version 2026-07-28. Available at: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;National Conference of State Legislatures, 2022. &lt;em&gt;2022 Security Breach Legislation.&lt;/em&gt; Available at: &lt;a href="https://www.ncsl.org/technology-and-communication/2022-security-breach-legislation" rel="noopener noreferrer"&gt;https://www.ncsl.org/technology-and-communication/2022-security-breach-legislation&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;National Institute of Standards and Technology, 2023. &lt;em&gt;Artificial Intelligence Risk Management Framework (AI RMF 1.0).&lt;/em&gt; NIST AI 100-1. Available at: &lt;a href="https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf" rel="noopener noreferrer"&gt;https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;National Institute of Standards and Technology, 2024. &lt;em&gt;Artificial Intelligence Risk Management Framework: Generative Artificial Intelligence Profile.&lt;/em&gt; NIST AI 600-1. Available at: &lt;a href="https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.600-1.pdf" rel="noopener noreferrer"&gt;https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.600-1.pdf&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;New York State Department of Financial Services, n.d. &lt;em&gt;23 NYCRR Part 500: Cybersecurity Requirements for Financial Services Companies&lt;/em&gt;, section 500.17. Available at: &lt;a href="https://www.dfs.ny.gov/cybersecurity/23-NYCRR-Part-500" rel="noopener noreferrer"&gt;https://www.dfs.ny.gov/cybersecurity/23-NYCRR-Part-500&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;OWASP GenAI Security Project, 2025. &lt;em&gt;OWASP Top 10 for Agentic Applications for 2026.&lt;/em&gt; December 2025. Available at: &lt;a href="https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/" rel="noopener noreferrer"&gt;https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/&lt;/a&gt; (document: &lt;a href="https://genai.owasp.org/download/52117/?tmstv=1765059207" rel="noopener noreferrer"&gt;https://genai.owasp.org/download/52117/?tmstv=1765059207&lt;/a&gt;) [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;OWASP GenAI Security Project, 2026. &lt;em&gt;OWASP GenAI LLM Top 10 2026.&lt;/em&gt; 3 August 2026. Available at: &lt;a href="https://genai.owasp.org/home/resources/owasp-genai-llm-top-10-2026/" rel="noopener noreferrer"&gt;https://genai.owasp.org/home/resources/owasp-genai-llm-top-10-2026/&lt;/a&gt; (document: &lt;a href="https://genai.owasp.org/download/56857/?tmstv=1785822482" rel="noopener noreferrer"&gt;https://genai.owasp.org/download/56857/?tmstv=1785822482&lt;/a&gt;) [Accessed 13 October 2026].&lt;/p&gt;

&lt;p&gt;Willison, S., 2025. &lt;em&gt;The lethal trifecta for AI agents: private data, untrusted content, and external communication.&lt;/em&gt; Simon Willison's Weblog, 16 June. Available at: &lt;a href="https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/" rel="noopener noreferrer"&gt;https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/&lt;/a&gt; [Accessed 13 October 2026].&lt;/p&gt;

</description>
      <category>agentic</category>
      <category>security</category>
      <category>gcp</category>
      <category>owasp</category>
    </item>
    <item>
      <title>Keeping Raw Data You May Be Asked to Delete</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:14:51 +0000</pubDate>
      <link>https://dev.to/sakurasky/keeping-raw-data-you-may-be-asked-to-delete-1ggi</link>
      <guid>https://dev.to/sakurasky/keeping-raw-data-you-may-be-asked-to-delete-1ggi</guid>
      <description>&lt;p&gt;Your first job on an erasure request is a search: where does this person's data sit? For a customer with one online order, the example estate in this series holds it in more than twenty places across the source database, the lakehouse, and the records around it. Some are obvious, such as the orders table and its bronze copy. Others are easy to miss: the change tables that feed replication, a month-end snapshot, the text of a query someone ran to look into a refund, and the table that caught a pipeline error. Each has its own rule for how long data stays after a delete, and several keep it for weeks after the DELETE statement succeeds.&lt;/p&gt;

&lt;p&gt;This series is for teams that have built a medallion lakehouse on Google Cloud and now have to run it. It assumes the layers are in place, as described in our post on &lt;a href="https://www.sakurasky.com/blog/data-lakehouse-medallion-architecture/" rel="noopener noreferrer"&gt;the medallion architecture&lt;/a&gt;. Part 1 starts with bronze, because bronze is where replay comes from and also where personal data is often kept longest, in its least processed form. The aim is an erasure process that finishes by a date the platform can compute for its own stores. That date applies to erased data. A lot of order data is kept on purpose under legal obligations, and this post keeps the two apart.&lt;/p&gt;

&lt;p&gt;The estate is a composite built for this series, close to the one in our &lt;a href="https://www.sakurasky.com/blog/qsr-retail-engineering-part-3/" rel="noopener noreferrer"&gt;post on multi-cloud retail&lt;/a&gt;: Microsoft Entra ID for workforce identity (which a later part on access uses), Google Cloud for the data platform, and Azure SQL Database under an online retailer's operational systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storage options this series refers to
&lt;/h2&gt;

&lt;p&gt;Several parts of this series depend on how bronze, silver, and gold are stored, so we give three storage options a letter here. A later part compares them in depth.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A. BigQuery native tables&lt;/strong&gt;, stored and managed entirely by BigQuery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B. Apache Iceberg managed tables&lt;/strong&gt; (formerly BigLake tables for Apache Iceberg in BigQuery). Data files sit in your Cloud Storage bucket and BigQuery manages them. Google garbage-collects data files after the time travel window, but not the files of a dropped table, and recommends keeping the bucket's default soft delete policy (Google Cloud, n.d.b).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C. Iceberg in an open catalog&lt;/strong&gt;, such as Google's Apache Iceberg REST catalog endpoint, with other engines writing the files and you running the file maintenance. BigQuery can't create or modify tables in that endpoint with DDL or DML (Google Cloud, n.d.y).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This part works through the design on options A and B, where BigQuery runs both the split job and the erasure job. On option C, those jobs run in Spark against the catalog instead. The file chain (delete files, compaction, snapshot expiry, and orphan cleanup) belongs with the storage comparison.&lt;/p&gt;

&lt;p&gt;Option B carries one constraint that shapes the design. Google lists authorised views as unsupported for managed Iceberg tables (Google Cloud, n.d.b). In this design, readers reach bronze, the retained store, and the identity table only through authorised views. So on option B those three stay native tables, as do the stores those views read (the suppression entries, the reopen table, the hand-off table, and the kept list). Managed Iceberg holds silver and gold, whose readers have table access anyway. If you put bronze on managed Iceberg, you have to give silver and analysts read access on the tables, and that lets them read deleted rows during the time travel window. The option B rows in the ledger and the worked example cover that case's timing. That read exposure comes on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this design follows the textbook medallion, and where it departs
&lt;/h2&gt;

&lt;p&gt;Google's guide to the medallion architecture describes four implementation steps (Google Cloud, n.d.ag). The first three build the layers. Ingestion moves data into bronze "without changing it", so no information is dropped or accidentally altered on the way, and bronze acts as a permanent, historical archive. Building silver uses Spark or SQL to apply data quality rules, such as converting timestamps to UTC or removing test accounts. Gold comes from SQL queries that join tables into metrics like revenue by category, often as high-performance views that a tool such as Looker can read directly.&lt;/p&gt;

&lt;p&gt;The fourth step, orchestration and governance, names Managed Service for Apache Airflow for scheduling. Its governance example is letting only a few authorised service accounts write to silver and gold. The page's list of Google Cloud products also names Dataform for building and scheduling the transformations that turn silver into gold (Google Cloud, n.d.ag).&lt;/p&gt;

&lt;p&gt;This series follows those steps, with SQL for every move between layers on options A and B, and Spark SQL on option C. It's one approach, and we've seen it work well where the data already sits in BigQuery: the work runs where the data is, with no separate cluster to run and no data to move out. On the estates we've worked on, it has usually been cheaper and faster to run, and quicker to build and change, than the same work in pipeline code. The right pattern depends on the data, team, and existing tooling, and another estate can call for another.&lt;/p&gt;

&lt;p&gt;The tool that runs that SQL varies by engagement. We've used Airflow, Dataform, and dbt (scheduled by the existing orchestrator or by dbt Cloud), usually whatever you already run. The example estate uses Managed Service for Apache Airflow. It schedules every job in this post and submits each SQL script to BigQuery as a job under that job's own service account. The infrastructure account creates the gold view once. The hardening section sets out the access rules for Airflow and what changes under Dataform or dbt.&lt;/p&gt;

&lt;p&gt;Personal data calls for one departure. Google's page doesn't cover it, and a bronze layer that keeps every customer's identifiers as a permanent archive makes the storage limitation and erasure duties hard to meet. So this series splits Google's bronze in two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A restricted raw dataset&lt;/strong&gt; takes the source rows as received, except the email (which in the main design never leaves the source), and keeps them only for a short retention period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bronze&lt;/strong&gt; holds the longer-lived history for replay, kept for its purpose periods, with direct identifiers replaced by a token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google's steps leave every change to the data until silver. This design tokenises before bronze, so the history kept longest holds the least.&lt;/p&gt;

&lt;p&gt;There are two smaller differences. Google says data scientists often want raw bronze data to train models. Here they get tokenised bronze through authorised views, and no person or analyst reads raw. Google also names Cloud Storage as the bronze landing zone. In this design raw lands in BigQuery through Datastream, and on option B, bronze's data files sit in Cloud Storage under BigQuery's management.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bronze is kept for, and what has to be kept anyway
&lt;/h2&gt;

&lt;p&gt;After launch, bronze usually serves three purposes: replaying history when a rule changes or a bug is found, answering audit questions about what arrived and when, and debugging pipelines. Each needs a different amount of history. In the estates we've seen, retention is easier to justify per purpose than as one number for the whole layer.&lt;/p&gt;

&lt;p&gt;GDPR's storage limitation principle in Article 5(1)(e) asks that personal data be kept in a form that permits identification of people "for no longer than is necessary for the purposes for which the personal data are processed" (European Parliament and Council, 2016). In California, section 1798.100(a)(3) of the Civil Code requires businesses to disclose how long they intend to keep each category of personal information or, if that isn't possible, the criteria used to decide it (California Legislature, n.d.a), and the regulations require the same in the notice at collection (California Privacy Protection Agency, n.d.a).&lt;/p&gt;

&lt;p&gt;Retention runs the other way too. Orders, invoices, payments, and returns are often subject to tax and accounting retention, and both regimes allow for this. Under Article 17(3)(b) and (e), GDPR's right to erasure doesn't apply to the extent processing is necessary "for compliance with a legal obligation" under Union or Member State law, or "for the establishment, exercise or defence of legal claims" (European Parliament and Council, 2016). Section 1798.105(d) of the CCPA has an exception for complying with a legal obligation (California Legislature, n.d.b). So the first step in this design is to sort event families into two groups. In the running example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Erased:&lt;/strong&gt; saved carts, recently viewed items stored by the application, wishlists, and reviews. Marketing preferences are erased too, with one exception covered below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kept under a stated obligation:&lt;/strong&gt; orders, order lines, invoices, payments, and returns, with shipment events following their orders. They're access-restricted and minimised, and recorded in the erasure log with their retention and basis. Where the obligation allows, they lose their token-to-person mapping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where an obligation requires identifying fields, such as the name and address on an invoice, those fields stay in the retained record under the record's own access and retention. You can still trace retained records to the person through the source system's own retained records, so they stay personal data for as long as those exist.&lt;/p&gt;

&lt;p&gt;That split is an illustration. Which records a business has to keep, and for how long, depends on where it trades. This post doesn't set out any country's retention periods.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to make bronze erasable
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rewrite on delete.&lt;/strong&gt; Keep identifiers in the events and delete or rewrite the person's rows wherever they are. It needs nothing extra at landing, but every erasure scans every table that might hold the person, and a missed table means a missed erasure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate identifiers with a random token.&lt;/strong&gt; Split identifiers from events soon after landing. It costs a split job and an identity table, and each erasure then starts from a short, known list of tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-subject encryption.&lt;/strong&gt; Encrypt each person's fields with their own key, and delete the key to make the data unreadable. Google describes deleting a key to render data unreadable as crypto-shredding, and BigQuery's AEAD functions can use keysets wrapped by Cloud KMS (Google Cloud, n.d.a). Each person's keyset would usually sit in a table. Deleting its row is then subject to that table's time travel, with fail-safe assumed as an upper bound.&lt;/p&gt;

&lt;p&gt;If the keysets are wrapped, destroying the wrapping KMS key version means no keyset wrapped by it can be unwrapped again. That suits shredding a whole dataset more than one person. The key material is destroyed only after a scheduled period of 30 days by default, and Google says it can then remain in its systems for up to 45 days from the scheduled destruction time (Google Cloud, n.d.j).&lt;/p&gt;

&lt;p&gt;Encryption also covers only the copies that stayed encrypted. Values decrypted into silver, gold, caches, or extracts aren't affected. When the KMS resource name and wrapped keyset are passed to &lt;code&gt;KEYS.KEYSET_CHAIN&lt;/code&gt; as constants, they can appear in the diagnostic query plan, though Google says the keys are never exposed in plaintext and decrypting the wrapped keyset needs permission on the Cloud KMS key (Google Cloud, n.d.e). NIST classes cryptographic erase as a purge technique for media sanitisation (National Institute of Standards and Technology, 2025). Its conditions include, as NIST says ISO/IEC 27040 specifies, being able to sanitise all copies of the key. In our reading, keyset rows held in time travel are copies. We haven't found EU guidance that treats key deletion as erasure, so this design uses crypto-shredding, where it uses it at all, alongside deletion rather than in place of it.&lt;/p&gt;

&lt;p&gt;Each fits a different context. Where your estate has many event tables and erasure requests arrive regularly, we've seen the token approach work well, with rewrite-on-delete for the few stores that have to hold identifiers. That's the design the rest of this post builds. If yours has few tables, or identifiers that can't leave the events, rewriting on delete may serve you better. Per-subject encryption suits retiring a whole dataset more than erasing one person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design in brief
&lt;/h2&gt;

&lt;p&gt;These terms carry most of the post.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source key:&lt;/strong&gt; the customer's account ID in the source system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer key:&lt;/strong&gt; a pseudonymous join key, an HMAC of the normalised email address, computed by one key service. Guest orders and carts carry one even though they have no source key. The split gives each guest customer key an identity row of its own, with no source key, so erasure finds a guest's records by customer key. Retained records carry the token in place of the customer key, for guests too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token:&lt;/strong&gt; a random value that stands in for the person on bronze events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity table:&lt;/strong&gt; the mapping from token to source key and customer key. Only the split and erasure jobs, and any named scanner, read or write it directly. Analysts and silver jobs get tokens and never the mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suppression entries:&lt;/strong&gt; the keys of people who have asked for erasure, checked by the split job before anything new is written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retained store:&lt;/strong&gt; the restricted home of event families kept under an obligation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Erasure time:&lt;/strong&gt; when the source system's erasure of the person took effect, fixed once by the erasure job and used to tell the person's old events from a returning customer's new ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request intake, queue, and erasure log:&lt;/strong&gt; the service account that records verified requests, the table where a request's keys wait for the erasure job, and the job's record of what it did, which holds no keys or tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand-off table and kept list:&lt;/strong&gt; where the erasure job passes a person's tokens, with a pass number, to the jobs that erase silver and gold; and the identity rows an obligation requires to be kept, with their basis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Horizon:&lt;/strong&gt; the computed date by which erased copies have left the pipeline's own stores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cutoff and landing check:&lt;/strong&gt; the last split run whose identity rows count as the person's own, recorded by the erasure job at its first step; and the test that the source erasure has reached the raw dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Figure 1 shows where one customer's record lands, with the backfill path drawn separately from CDC.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-11-lakehouse-erasure-where-record-lands.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-11-lakehouse-erasure-where-record-lands.png" title="Figure 1. Where one customer's record lands." alt="Figure 1. Where one customer's record lands in the example estate. The source holds application tables, change tables, and backups of both. CDC and backfill bring rows into the restricted raw dataset. The split job works from its staging tables, checks each event against the suppression entries and adds suppressed record keys, and writes bronze events, the retained store, identity rows, and marks for the raw rows it has finished with, identity-source rows among them, to which the erasure job adds 'erased' marks. Keys, mappings, and erasure records sit in an isolated group: the suppression entries and suppressed record keys, the erasure queue, the reopen table, the identity table, with one row per source key, or for guests one per customer key and token, and the hand-off table and kept list. Request intake appends the person's keys to the suppression entries and writes the queue row. Silver reads bronze through an authorised view and the retained store through a minimised view that nulls tokens on the kept list or in the hand-off table; gold reads silver, and other downstream recipients are fed from gold or the export view. The export view takes token and customer key from the identity table and excludes erased people and reopened tokens. Copies and records can hold any of these stores, raw and bronze among them. The landing, split, and isolated stores follow the isolation rule in the hardening section, and on option B bronze, the retained store, the identity table, and the stores their views read stay native tables." width="800" height="1590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One customer, end to end
&lt;/h2&gt;

&lt;p&gt;Take one account holder with a wishlist and one order. In the application's customers table, their row holds their email next to the customer key the key service returned. Their wishlist and order rows hold their source key.&lt;/p&gt;

&lt;p&gt;Datastream copies changes to all three rows into the restricted raw dataset, leaving out the email. From there the split job does three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It creates an identity row, one per source key, with a new token, the source key, and the customer key.&lt;/li&gt;
&lt;li&gt;It writes the wishlist events to bronze with the token in place of the account ID.&lt;/li&gt;
&lt;li&gt;It writes the order to the retained store, also with the token, because orders are kept under an obligation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An analyst who counts wishlist items by category reads a gold metric with no token in it. That metric is built from a silver table that carries the token.&lt;/p&gt;

&lt;p&gt;A month later, the customer asks for erasure. Request intake adds their source key and customer key to the suppression entries, so no new parent event keyed to them reaches bronze. Child rows of records already in bronze are removed in steps 1 and 3. The source system erases the customer row on its own schedule, and the erasure job works through its steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It deletes the wishlist events by token (step 1).&lt;/li&gt;
&lt;li&gt;It waits for any split run already under way, and checks again (steps 2 and 3).&lt;/li&gt;
&lt;li&gt;It hands the token to the jobs that erase silver and gold (step 4).&lt;/li&gt;
&lt;li&gt;Once they confirm, it deletes the identity row and clears the token from the retained order (step 5).&lt;/li&gt;
&lt;li&gt;When the source erasure has landed, it deletes the customer's rows from raw (step 6).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The order stays in the retained store, linked to its invoice by order ID. It has no token and no mapping back to the person, though it keeps any identifying fields its obligation requires. Bronze, the identity table, and raw still hold the deleted rows for their time travel and fail-safe windows, and the post works out when those windows close.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tables, and the SQL between the layers
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Table 1. Tables in the listings.&lt;/strong&gt; It groups them by layer. We explain each column where its rule comes up: the erasure time and cutoff in stage 2, and split runs in the split job section. The names are illustrative. Each erased family, such as carts, has tables of the same shape.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;th&gt;Written by&lt;/th&gt;
&lt;th&gt;Read by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Raw&lt;/td&gt;
&lt;td&gt;Source rows as received, minus the email&lt;/td&gt;
&lt;td&gt;Datastream&lt;/td&gt;
&lt;td&gt;Split, erasure job, cleanup job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;staging.wishlist_changes&lt;/code&gt;, &lt;code&gt;staging.wishlist_item_changes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Raw&lt;/td&gt;
&lt;td&gt;Raw rows the split hasn't processed yet&lt;/td&gt;
&lt;td&gt;Split, as new tables each run, named per run (shown without the suffix), each with a one-day expiry&lt;/td&gt;
&lt;td&gt;Split&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;identity.identity_map&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keys&lt;/td&gt;
&lt;td&gt;Token, source key, customer key, and the split run that set the key (&lt;code&gt;key_set_run&lt;/code&gt;): one row per source key, or for guests one per customer key and token, so a returning guest gets a second row&lt;/td&gt;
&lt;td&gt;Split; the erasure job deletes rows&lt;/td&gt;
&lt;td&gt;Split, erasure job, export view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;suppression.source_keys&lt;/code&gt;, &lt;code&gt;suppression.customer_keys&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Keys&lt;/td&gt;
&lt;td&gt;Erased keys with their request ID, request time (&lt;code&gt;requested_at&lt;/code&gt;), and erasure time (&lt;code&gt;erasure_ts&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Intake appends; the erasure job fixes the erasure time&lt;/td&gt;
&lt;td&gt;Split, erasure job, export view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;suppression.record_keys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keys&lt;/td&gt;
&lt;td&gt;Suppressed record keys with their family, request ID, and how far each reaches (&lt;code&gt;suppress_through&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Split, erasure job&lt;/td&gt;
&lt;td&gt;Split, erasure job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;erasure.queue_keys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Erasure&lt;/td&gt;
&lt;td&gt;The request's queue row: its keys and record keys, one table row each, every one carrying the request's cutoff (&lt;code&gt;cutoff_run&lt;/code&gt;) once step 1 records it&lt;/td&gt;
&lt;td&gt;Intake, erasure job&lt;/td&gt;
&lt;td&gt;Erasure job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;split_marks.marked_rows&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Raw&lt;/td&gt;
&lt;td&gt;Raw rows finished with, by row ID, reason, record key, and request ID&lt;/td&gt;
&lt;td&gt;Split, erasure job&lt;/td&gt;
&lt;td&gt;Split, cleanup job, erasure job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ops.split_runs&lt;/code&gt;, &lt;code&gt;ops.committed_split_runs&lt;/code&gt;, &lt;code&gt;ops.merge_watermarks&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;Run numbers and start times only&lt;/td&gt;
&lt;td&gt;Split; silver merge&lt;/td&gt;
&lt;td&gt;Split staging, silver merge, erasure job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;bronze.wishlists&lt;/code&gt;, &lt;code&gt;bronze.wishlist_items&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Bronze&lt;/td&gt;
&lt;td&gt;Events with the token, record keys, the raw row each came from (&lt;code&gt;raw_row_id&lt;/code&gt;), the split run that wrote them (&lt;code&gt;split_run&lt;/code&gt;), and an ordering field for changes in the same instant (&lt;code&gt;change_seq&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Split; the erasure and bronze retention jobs delete&lt;/td&gt;
&lt;td&gt;Split, erasure job; silver through &lt;code&gt;bronze_views&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bronze_views.wishlist_items&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bronze&lt;/td&gt;
&lt;td&gt;An authorised view, storing nothing&lt;/td&gt;
&lt;td&gt;Infrastructure account&lt;/td&gt;
&lt;td&gt;Silver merge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;silver.wishlist_items&lt;/code&gt;, &lt;code&gt;silver.orders&lt;/code&gt;, &lt;code&gt;silver.products&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Silver&lt;/td&gt;
&lt;td&gt;Cleaned rows; items and orders carry the token&lt;/td&gt;
&lt;td&gt;Silver merges (only the items merge is shown); silver's erasure job deletes items and clears order tokens&lt;/td&gt;
&lt;td&gt;Gold view (items and products); other gold jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gold.wishlist_items_by_category&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gold&lt;/td&gt;
&lt;td&gt;A view of counts, storing nothing and carrying no token&lt;/td&gt;
&lt;td&gt;Infrastructure account&lt;/td&gt;
&lt;td&gt;Dashboards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;erasure_handoff.tokens&lt;/code&gt;, &lt;code&gt;erasure_confirm_silver.confirmations&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Erasure&lt;/td&gt;
&lt;td&gt;Tokens with request ID and pass number; confirmations with no token&lt;/td&gt;
&lt;td&gt;Erasure job; silver's erasure job&lt;/td&gt;
&lt;td&gt;Downstream erasure jobs; erasure job&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Listing 1 traces the wishlist from bronze to gold, in the shape of Google's steps. A MERGE rebuilds each changed item in silver from its bronze history. Along the way it converts timestamps to UTC and drops test accounts. A view then joins silver tables into a gold metric. You'll find the split that writes bronze in Listing 2, in the split job section, and the erasure DELETEs in Listing 3, in stage 2. Each block runs as its own script. Silver reads and moves its watermark inside its own transaction, so it needs no per-run values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Two scripts: the merge into silver, run after each committed split run,&lt;/span&gt;
&lt;span class="c1"&gt;-- and the gold view, created once.&lt;/span&gt;

&lt;span class="c1"&gt;-- Bronze to silver: recompute each changed item from its full bronze history,&lt;/span&gt;
&lt;span class="c1"&gt;-- read through an authorised view, up to the latest committed split run.&lt;/span&gt;
&lt;span class="c1"&gt;-- Timestamps become UTC, and an item whose latest change is a delete, or&lt;/span&gt;
&lt;span class="c1"&gt;-- comes from a test account, leaves silver. The watermark is read and moved&lt;/span&gt;
&lt;span class="c1"&gt;-- inside the transaction, so this script needs no per-run values.&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="n"&gt;last_merged&lt;/span&gt; &lt;span class="n"&gt;INT64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="n"&gt;merge_to&lt;/span&gt; &lt;span class="n"&gt;INT64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;last_merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_merged_run&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merge_watermarks&lt;/span&gt;
                   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'silver.wishlist_items'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;merge_to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;committed_split_runs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;MERGE&lt;/span&gt; &lt;span class="n"&gt;silver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;added_at_utc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_test&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;added_at_local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time_zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;added_at_utc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_test_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;is_test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
                              &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_seq&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bronze_views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;
      &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bronze_views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;split_run&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;last_merged&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;split_run&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;merge_to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;
      &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;
     &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;merge_to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DELETE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UPDATE-DELETE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_test&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;added_at_utc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;added_at_utc&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_type&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DELETE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UPDATE-DELETE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_test&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;added_at_utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;added_at_utc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;MERGE&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merge_watermarks&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'silver.wishlist_items'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;last_merged_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;merge_to&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;MATCHED&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_merged_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;merge_to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Silver to gold: a plain view that joins silver tables into a metric for&lt;/span&gt;
&lt;span class="c1"&gt;-- dashboards. It stores nothing and carries no token or key. The&lt;/span&gt;
&lt;span class="c1"&gt;-- infrastructure account creates it once and authorises it on silver's&lt;/span&gt;
&lt;span class="c1"&gt;-- dataset, so its readers need no access to silver; no job recreates it.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items_by_category&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;added_at_utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;day_added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;silver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;silver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;day_added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 1. Bronze to silver and silver to gold for one wishlist, in BigQuery SQL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On option A, the gold view is authorised on silver's dataset, so dashboard readers don't need access to the silver tables that carry the token. If silver is a managed Iceberg table, the view can't be authorised. In that case gold is a table built by its own registered job.&lt;/p&gt;

&lt;p&gt;A plain view like this gold one stores nothing, so it needs no erasure job of its own. Silver can only be rebuilt from bronze, and by the time the token is handed off, bronze holds nothing for it. Gold is different when it's a table, or, on option A, a materialised view built for speed (our reading of Google's "high-performance views"). Then it holds aggregates computed from the person's rows until it's refreshed or rebuilt. A table has its own time travel, and a materialised view needs the same check. That gold job registers for the hand-off and confirms only after the refresh. Where aggregates are published widely, this design also suppresses small counts, because a count of one can single someone out.&lt;/p&gt;

&lt;p&gt;Silver tables built from retained families, such as orders, keep their rows at erasure. Silver's erasure job clears their token, as step 5 does in the retained store, so revenue in gold doesn't move. A later part of this series covers what gold readers see when published numbers do change.&lt;/p&gt;

&lt;p&gt;The rest of the post fills in each part: keeping the email out of capture, what the split writes, the two stages of erasure and their edge cases, marketing preferences, the stores to account for, and the date. The design leans on access controls throughout, so we've gathered them in one section near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the email address out at source
&lt;/h2&gt;

&lt;p&gt;Our &lt;a href="https://www.sakurasky.com/blog/qsr-retail-engineering-part-3/" rel="noopener noreferrer"&gt;post on multi-cloud retail&lt;/a&gt; computed the customer key in one key service, the only identity with sign and verify permission on the secret key in Cloud KMS. It also set out the choice between replicating raw addresses into the data platform and keying at source. This part takes the keying-at-source route and adds the mechanism for an estate fed by Datastream.&lt;/p&gt;

&lt;p&gt;The email gets this treatment because it's the input to the join key. Other identifiers, such as names, phone numbers, delivery addresses, and free text, do land in the raw dataset. From there the split moves them to the identity side, or keeps them in a retained record whose obligation needs them.&lt;/p&gt;

&lt;p&gt;The source application stores the customer key the key service returns. That post allows records to be stored first and keyed asynchronously. Neither the database nor the application holds the secret key, though the application can still request keys. Because the source database holds each email next to its customer key, its change tables and backups are on the ledger. The hardening section covers the controls that keep the key service's key to itself.&lt;/p&gt;

&lt;p&gt;Two controls keep the email out of the lakehouse, one for each way data leaves the source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Change data capture.&lt;/strong&gt; Datastream supports Azure SQL Database only with the change tables CDC method, and lists it at service tier S3 and above (Google Cloud, n.d.aa). The &lt;code&gt;@captured_column_list&lt;/code&gt; parameter limits which columns reach a change table, and Microsoft documents it for Azure SQL Database where only some columns should be tracked "for privacy or performance reasons" (Microsoft, 2025b). Primary key columns, or the columns of the unique index named with &lt;code&gt;@index_name&lt;/code&gt;, must be captured (Microsoft, 2026h), so the email mustn't be part of either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backfill.&lt;/strong&gt; A Datastream backfill streams a snapshot of the existing data (Google Cloud, n.d.o). We don't rely on the capture instance's column list to govern it. The stream's API schema allows columns to be listed under its included and excluded objects (Google Cloud, n.d.v). We'd list the allowed columns under included objects and treat that list as the control for backfills. A column added later then stays out until someone adds it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A table over 500 million rows can be backfilled only if it has a unique index with no nullable columns and every index column is in the stream (Google Cloud, n.d.aa). That's one more reason to keep the email out of such indexes.&lt;/p&gt;

&lt;p&gt;When a stream starts, Datastream selects the latest capture instance, doesn't switch to one created afterwards, and skips a table whose selected instance is deleted (Google Cloud, n.d.aa). So the instance that excludes the email has to exist before the stream starts.&lt;/p&gt;

&lt;p&gt;If CDC is already running with every column, you recreate the stream against the new instance, usually with a backfill, and only then drop the original instance. Dropping it removes its change table (Microsoft, 2026g), but backups taken before then still contain it. SQL Server allows two capture instances per table (Microsoft, 2026h), so adding a source column becomes a planned change. A later part covers it.&lt;/p&gt;

&lt;p&gt;This design leaves Datastream's &lt;code&gt;enable_ddl_support_for_ct&lt;/code&gt; label off. With the label on, Datastream creates and switches to a new capture instance when a column is added (Google Cloud, n.d.aa), and the documentation doesn't say which columns that instance captures. Without the label, columns added after the stream starts don't replicate, and that's what this design wants. Masking the column isn't a workaround either. Datastream doesn't support tables with masked columns, and where they are replicated, the values arrive unmasked (Google Cloud, n.d.aa). Datastream's own login on the source is a privileged account, and the hardening section covers it.&lt;/p&gt;

&lt;p&gt;If you can't change the source application, one fallback is a BigQuery remote function that calls a batch endpoint of the key service from the split job. It comes at a cost. Because the email is captured, it stays in the change tables and the raw dataset for their full retention. The schema check allowlists that one column, and the split job keys it and doesn't carry it forward. The worked example in "Computing the date" assumes keying at source. Under the fallback, its change-table and raw chains would also be chains for the email itself. The hardening section covers the endpoint's controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split job
&lt;/h2&gt;

&lt;p&gt;Datastream writes to BigQuery in merge mode by default, which keeps no history of change events (Google Cloud, n.d.f). A lakehouse that replays from bronze needs that history, so the example estate uses append-only mode. There, an update becomes a single UPDATE-INSERT row, an update that changes the primary key becomes an UPDATE-DELETE and UPDATE-INSERT pair, and a delete becomes one DELETE row (Google Cloud, n.d.f). Datastream applies these through the Storage Write API over gRPC (Google Cloud, n.d.f). Rows written that way can be changed by UPDATE, DELETE, or MERGE, with no waiting period stated (Google Cloud, n.d.ac). The erasure steps below rely on that.&lt;/p&gt;

&lt;p&gt;Datastream can write every table into a single dataset (Google Cloud, n.d.f). The example estate creates that raw dataset beforehand, because otherwise a new BigQuery dataset gives READER, WRITER, and OWNER access to project Viewers, Editors, and Owners, and OWNER access to its creator (Google Cloud, n.d.c). Its access list names only the split job, the erasure job, the raw cleanup job, and any named scanner. Datastream writes through its project-level service agent. Raw, bronze, the identity table, the suppression entries, and the other restricted stores all follow one isolation rule, set out in the hardening section.&lt;/p&gt;

&lt;p&gt;Datastream applies partitioning only when it creates a table, and you configure it in the stream (Google Cloud, n.d.r). So ingestion-time partitioning goes in before the stream first runs. Expiration alone doesn't give a date: an expired partition is removed by a background process that "can take several days to complete", and that deletion isn't recorded in audit logs (Google Cloud, n.d.p). A deleted partition's data is then kept under time travel and fail-safe (Google Cloud, n.d.p).&lt;/p&gt;

&lt;p&gt;So the erasure job deletes the person's raw rows itself, and a scheduled cleanup job deletes everything else older than the raw retention period. The cleanup job skips rows that the split is holding back or that are waiting for a parent. A hold older than a stated threshold pages someone.&lt;/p&gt;

&lt;p&gt;Expiration stays as a backstop, set longer than the raw retention period plus that hold threshold. The split records the raw rows it has finished with in its marks table, &lt;code&gt;split_marks.marked_rows&lt;/code&gt;, with a reason: 'copied' for retained-family rows it has sent to the retained store, 'suppressed' for rows it dropped, 'orphaned' for erased-family children whose parent never arrived, and 'identity' for rows from identity sources, such as the customers table, once their fields are on the identity side. On its next daily run, the cleanup job removes marked rows, including the erasure job's 'erased' rows. It deletes a mark once its raw row is gone. Expiration set as a dataset default affects newly created tables (Google Cloud, n.d.ad), so it goes in before the stream creates its tables, or per table afterwards with &lt;code&gt;ALTER TABLE ... SET OPTIONS&lt;/code&gt; (Google Cloud, n.d.p).&lt;/p&gt;

&lt;p&gt;The split job is written in BigQuery SQL, runs from Airflow on options A and B, and writes three things. Airflow starts each split run one at a time, with a run timeout. Each run begins with a task that runs as the split account. It takes the next run number from a counter table, &lt;code&gt;ops.split_runs&lt;/code&gt;, and records its start time there. Numbers therefore increase by start time, and a retry gets a new number. Without that, Airflow would run the retry under the same run. That task also waits until &lt;code&gt;INFORMATION_SCHEMA.JOBS&lt;/code&gt; shows no earlier split job still pending or running, so two runs never overlap. A run that times out is cancelled when Airflow kills its task, and a job timeout set on the BigQuery script ends it even if the worker running the task dies.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The identity table&lt;/strong&gt; maps a random token to the source key and the customer key. Clear identifiers from erased families are kept only where a named purpose needs them (for example a coarsened delivery postcode). Each has its own retention, which the erasure job enforces, and the purpose reads it through its own authorised view. Free text counts as an identifier. Erasure deletes the whole identity row, these identifiers included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bronze events&lt;/strong&gt; carry the token in place of both the direct identifiers and the customer's source key. Record keys such as order and cart IDs stay, because deduplication and child lookups need them. Those keys can be joined back to the person through the source system while its records are kept. That's one more reason to treat the events as personal data. The events are clustered by token, and only the split job inserts them. A bronze retention job deletes erased-family events once they pass the longest of bronze's purpose periods, so bronze isn't kept indefinitely for people who never ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retained store&lt;/strong&gt; holds event families kept under an obligation, including any identifying fields the obligation requires. They don't go to bronze. While the mapping exists, they carry the token like bronze events. Silver reads them through a minimised view that returns the token as null for any token on the kept list or in the erasure hand-off table (the view is authorised on both datasets). That way, a retained record merged into silver after silver's erasure job has run doesn't bring the token back. A retention job deletes records when their obligation period ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Child records, such as cart lines, wishlist items, order lines, and shipments, get their token from the token-keyed parent event. A child that arrives before its parent waits in the raw dataset. One whose parent never arrives is counted. In an erased family, it's then marked 'orphaned' for the cleanup job. In a retained family it goes to the retained store unlinked.&lt;/p&gt;

&lt;p&gt;When the split suppresses a parent in an erased family, it writes that parent's record key, with its family (cart or wishlist) and the request ID from the matching entry, to a table of suppressed record keys kept with the suppression entries. A source-key match suppresses every event of that record. A customer-key match covers events up to the erasure time, or up to the request while that time isn't fixed. The erasure job's own entries, from step 1, cover every event. A suppressed parent's children are then dropped and counted separately. Every suppression entry carries its request ID.&lt;/p&gt;

&lt;p&gt;Listing 2 is the split for the wishlist family. It writes a wishlist event to bronze only when no suppression entry covers it. For each wishlist it suppresses, it records the key and marks those rows and their items for the cleanup job. The other items take their token from the wishlist's latest event in bronze. Each split run's statements share one transaction, so a failed run leaves nothing in bronze. The listing leaves out the 'orphaned' marks, the retained-store writes with their 'copied' marks, and the identity rows. The split writes those identity rows under the same suppression check.&lt;/p&gt;

&lt;p&gt;Each bronze row carries the &lt;code&gt;raw_row_id&lt;/code&gt; it came from. A raw row therefore counts as processed once it's in bronze, the retained store, or the marks table, from a committed split run or the erasure job. Because that column links bronze back to raw, only holders of raw access can read it. The raw retention period is set shorter than bronze's shortest purpose period, so a row the bronze retention job deletes is never copied back. A halted cleanup job breaks that ordering as well as the raw dates. So it pages someone, and its delay moves the date the same way a halted split does.&lt;/p&gt;

&lt;p&gt;The split's run number is a named query parameter, written with &lt;code&gt;@&lt;/code&gt; (Google Cloud, n.d.w). The run's first task takes it from &lt;code&gt;ops.split_runs&lt;/code&gt; and passes it to the script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Raw to bronze (split job). The split's staging tables hold raw rows it&lt;/span&gt;
&lt;span class="c1"&gt;-- hasn't processed yet: rows not in bronze, the retained store, or the marks&lt;/span&gt;
&lt;span class="c1"&gt;-- table under any reason, including the erasure job's 'erased' marks, which&lt;/span&gt;
&lt;span class="c1"&gt;-- carry no split run. They're written as new tables before this transaction,&lt;/span&gt;
&lt;span class="c1"&gt;-- named per run (shown here without the suffix), each with a one-day expiry.&lt;/span&gt;
&lt;span class="c1"&gt;-- Each run's statements run in one transaction, so a failed run leaves nothing&lt;/span&gt;
&lt;span class="c1"&gt;-- in bronze. Not shown: identity rows and their 'identity' marks, guest&lt;/span&gt;
&lt;span class="c1"&gt;-- records keyed by customer key, retained-store writes with their 'copied'&lt;/span&gt;
&lt;span class="c1"&gt;-- marks, and 'orphaned' marks for children whose parent never arrives.&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Parents: write a wishlist event only when no suppression entry covers it.&lt;/span&gt;
&lt;span class="c1"&gt;-- Rows it skips stay in raw, either suppressed or held back until the&lt;/span&gt;
&lt;span class="c1"&gt;-- person's erasure time is known. Identity rows are written under the&lt;/span&gt;
&lt;span class="c1"&gt;-- same check.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlists&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change_seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_changes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_map&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'wishlist'&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suppress_through&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suppress_through&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;-- Record the key of each wishlist this run suppressed, with the covering&lt;/span&gt;
&lt;span class="c1"&gt;-- entry's request ID and how far it reaches: a source-key match covers&lt;/span&gt;
&lt;span class="c1"&gt;-- every row (NULL), a customer-key match covers rows up to the erasure&lt;/span&gt;
&lt;span class="c1"&gt;-- time, or up to the request while that time isn't fixed. Held-back rows&lt;/span&gt;
&lt;span class="c1"&gt;-- aren't suppressed yet. A source-key match counts even after the&lt;/span&gt;
&lt;span class="c1"&gt;-- person's identity row has been deleted.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_keys&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_family&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suppress_through&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="s1"&gt;'wishlist'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requested_at&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
       &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_changes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_map&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt;
 &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requested_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Mark the suppressed parent and child rows once each, for counting and for&lt;/span&gt;
&lt;span class="c1"&gt;-- the cleanup job.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;split_marks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;marked_rows&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                     &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'suppressed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suppress_through&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_changes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'wishlist'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suppress_through&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_item_changes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'wishlist'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;suppress_through&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;suppress_through&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- The other children take their token from the parent's latest event in&lt;/span&gt;
&lt;span class="c1"&gt;-- bronze, under the same customer-key check as the parent. Items whose parent&lt;/span&gt;
&lt;span class="c1"&gt;-- hasn't arrived stay in raw for a later run. A child written after intake but&lt;/span&gt;
&lt;span class="c1"&gt;-- before its account's identity row has a customer key is caught by steps 1&lt;/span&gt;
&lt;span class="c1"&gt;-- and 3.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change_seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;added_at_local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;time_zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_test_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change_seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;added_at_local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time_zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_test_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_item_changes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;wishlist_id&lt;/span&gt;
                              &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change_seq&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_map&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
      &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;erasure_ts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'wishlist'&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suppress_through&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suppress_through&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;committed_split_runs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 2. Raw to bronze for the wishlist family, in BigQuery SQL: one split run, in one transaction.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A schema check runs on the raw tables every time. If a column's name suggests an email address, the run stops and pages someone. The same happens when a content check finds email addresses in a column not already routed to the identity side. Other unrecognised columns go to the identity side. A halted split job pages someone well inside the raw retention period, and it also holds up erasures, as step 6 below shows.&lt;/p&gt;

&lt;p&gt;Downstream, silver jobs and analysts work with tokens. Some uses need the cross-system customer key, such as the campaign audiences our earlier post described leaving the platform as customer keys. For those, a named export job resolves tokens through one authorised view. The view returns token and customer key only, and it leaves out an erased person's own identity rows. The hardening section gives its rule. Whatever the job exports goes on the ledger with its recipient.&lt;/p&gt;

&lt;p&gt;The events are still personal data. As our &lt;a href="https://www.sakurasky.com/blog/qsr-retail-engineering-part-3/" rel="noopener noreferrer"&gt;earlier post&lt;/a&gt; set out, Recital 26 says pseudonymised data that could be attributed to a person with additional information should be considered information on an identifiable person (European Parliament and Council, 2016). The EDPB's 2025 coordinated enforcement action found that some controllers relying on anonymisation in place of deletion applied only basic pseudonymisation or partial masking, which "would not fulfil the requirements of the GDPR regarding deletion" (European Data Protection Board, 2026).&lt;/p&gt;

&lt;p&gt;California's regulations, by contrast, let a business comply with a deletion request by deidentifying or aggregating the information (California Privacy Protection Agency, n.d.b), so the bar differs from one regime to another. Section 1798.140(m) of the CCPA defines deidentified information as information that "cannot reasonably be used to infer information about, or otherwise be linked to, a particular consumer", provided the business also takes reasonable measures, commits publicly to keep it deidentified, and binds recipients by contract (California Legislature, n.d.d). As we read it, tokenised events with a live mapping and record keys wouldn't meet that. Where this post says a source system anonymises a record, it means the record can no longer identify the person. The split shortens the search for a person's events. The erasure job below still deletes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Erasure in two stages
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-11-lakehouse-erasure-two-stages.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-11-lakehouse-erasure-two-stages.png" title="Figure 2. Erasure in two stages." alt="Figure 2. Erasure in two stages. At the request, intake appends the person's keys to the suppression entries, then the queue row. Split runs check every event against the entries and skip raw rows already marked, and the source system erases or anonymises the record on its own clock and signals intake when it's done. The erasure job checks the queue keys against the suppression entries, records the cutoff, looks up tokens, copies record keys, deletes erased-family events, and marks their raw rows 'erased' (step 1). It waits at least one split-run timeout and until in-flight runs show as done, paging after a margin (step 2), then looks up again with a confirming DELETE until nothing new is found (step 3). It hands tokens to the silver, gold, and feature-store erasure jobs with a pass number (step 4). Once every job confirms, or after a timeout with an alert, it deletes the mapping and clears retained-record tokens, or, where an obligation requires the mapping, moves it to the kept list and keeps those tokens; it removes the hand-off rows last, only when every job has confirmed (step 5). The daily sweep fixes the erasure time after step 1 records the cutoff and once the landing check passes, paging if that takes longer than the stated period. Once the time is fixed and the split has processed every earlier row, the job deletes the person's processed raw rows and their marks in one transaction, then the queue row (step 6). Each of the pipeline's own stores then leaves recoverable storage after its own windows." width="800" height="1934"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Figure 2 shows the two stages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 1, at the request
&lt;/h3&gt;

&lt;p&gt;Only the request-intake service account records verified requests. IAM can't tell whether a request is verified, so intake's application makes that check. It appends the person's keys to the suppression entries first. The queue row goes last, and only after the entries' appends are acknowledged, so the erasure job never sees a request whose entries aren't there.&lt;/p&gt;

&lt;p&gt;The keys come from the source. At intake, the source application uses its existing path to the key service. It sends every address it holds or has held for the person and verified for the account, plus the verified address the requester supplied. It passes the returned customer keys, computed under every enabled key version, to intake. Stored addresses matter because the requester may type a different one, and older guest orders may carry an older address. The person's source key and customer keys then go on the suppression entries and in the queue. You need both, because a guest order carries a customer key but no account's source key.&lt;/p&gt;

&lt;p&gt;The source erasure itself runs on its own clock. When it finishes, the source tells intake, and intake appends that signal to the queue. The source also clears the customer key from the records it keeps, so a later backfill can't link them to the person again.&lt;/p&gt;

&lt;p&gt;The split job checks every event against the entries. The rules apply top to bottom, and the first match wins:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A change row from an identity source, such as the customers table, including its DELETE or anonymising UPDATE-INSERT row at source erasure&lt;/td&gt;
&lt;td&gt;Any time&lt;/td&gt;
&lt;td&gt;Identity fields go to the identity side, except that no identity row is written or updated for an erased source key or a covered customer key; the raw row is marked 'identity' either way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The DELETE row, or anonymising UPDATE-INSERT row, for the source erasure&lt;/td&gt;
&lt;td&gt;Erased family, any timestamp&lt;/td&gt;
&lt;td&gt;Suppressed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other erased-family event for an erased source key&lt;/td&gt;
&lt;td&gt;Dated after the erasure time&lt;/td&gt;
&lt;td&gt;Suppressed, and raised with the source system's owner as a sign of a reused key or an incomplete source erasure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Erased-family event with an erased source key&lt;/td&gt;
&lt;td&gt;Any time&lt;/td&gt;
&lt;td&gt;Suppressed, and the source key's entry is kept while retained events for that key can still arrive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retained-family event with an erased source key&lt;/td&gt;
&lt;td&gt;Any time&lt;/td&gt;
&lt;td&gt;Sent to the retained store without a token and with the source key and customer key dropped, with no new identity row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Erased-family event with an erased customer key&lt;/td&gt;
&lt;td&gt;Dated at or before the request, or at or before the erasure time once it's fixed&lt;/td&gt;
&lt;td&gt;Suppressed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retained-family event with an erased customer key&lt;/td&gt;
&lt;td&gt;Dated at or before the request, or at or before the erasure time once it's fixed&lt;/td&gt;
&lt;td&gt;Sent to the retained store without a token, with the customer key and source key dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any event with an erased customer key&lt;/td&gt;
&lt;td&gt;Dated after the request, erasure time not yet known&lt;/td&gt;
&lt;td&gt;Held back in raw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event with the same customer key&lt;/td&gt;
&lt;td&gt;Dated after the erasure time&lt;/td&gt;
&lt;td&gt;Treated as a returning customer and given a new token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of these rules carry a cost. The source system's own retained records keep the account's source key for as long as the source keeps them, often years. Source-key entries last that long too, and it's worth saying so in the retention notice. Customer-key entries reveal that a returning customer was erased before, so they expire once no pre-erasure event can still land. That point depends on change-table retention, landing lag, and any backfill window. The erasure job deletes suppression entries when their retention ends.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixing the erasure time
&lt;/h3&gt;

&lt;p&gt;The erasure job (its steps follow below) fixes the erasure time once. It does so on its first daily sweep after both of these are true: the landing check has passed for every table where the person has rows, and step 1 has recorded the request's cutoff. The job takes the latest source timestamp among the person's erasure change rows and sets it on their customer-key suppression entries, updating intake's rows. On the same entries it records the latest split run that finished before the erasure time was written. The split and the export view read that value. Both values stay on the entries after the queue row is gone.&lt;/p&gt;

&lt;p&gt;Sometimes there's nothing to erase at source, because the source purged the person's records long ago while bronze kept them. In that case the source reports the time it checked, and the erasure job accepts that time as the erasure time once a heartbeat row has landed past it.&lt;/p&gt;

&lt;p&gt;The landing check needs a watermark that always advances, because a table's latest source timestamp stops moving when nothing in it changes. The check runs only on tables where the person has rows. It reads Datastream's source timestamp metadata, which Google defines as the time the record changed on the source (Google Cloud, n.d.l).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;The check waits for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A table where the source erasure deleted the person's rows&lt;/td&gt;
&lt;td&gt;Their DELETE rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A table where the source erasure anonymised the person's rows&lt;/td&gt;
&lt;td&gt;The change that clears the customer key, which touches a captured column and so produces a change row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any other table where the person has rows&lt;/td&gt;
&lt;td&gt;A heartbeat row, written every few minutes to a small captured table holding no personal data, landed with a source timestamp past the source's signal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Stage 2, the erasure job
&lt;/h3&gt;

&lt;p&gt;The job picks up new requests on a daily sweep. From there it runs each request as a state machine that wakes when one of its waits ends. Fixing the erasure time and step 6 both happen on the daily sweep.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check that every key in the queue row is present in the suppression entries. If any is missing, stop and page someone. Then record the request's cutoff: the latest split run that started before this step began. A run starts when its number is taken, and run IDs increase by start time. Look up the person's tokens. Copy their erased-family parent record keys (cart and wishlist IDs) from bronze into the queue and into the suppressed record keys, each with its family and no date limit, because every event of the person's own records goes. Copy their order IDs from the retained store into the queue only, since order lines and refunds must still reach the retained store. Then DELETE their events in erased families, marking their raw rows 'erased' (Listing 3).&lt;/li&gt;
&lt;li&gt;Wait until every split run that began before step 1's record-key insert has finished. Split runs are serialised and time out. The job waits until &lt;code&gt;INFORMATION_SCHEMA.JOBS&lt;/code&gt; shows no job by the split's service account, started before the insert, still pending or running, and until at least one split-run timeout has passed since the insert. That view's job states are pending, running, and done (Google Cloud, n.d.m). A timed-out run is cancelled, and its jobs can take a while to stop, so the wait ends only when the view shows them done. If it hasn't after a stated margin past the timeout, the job pages someone and keeps waiting. For this, the erasure job can read job metadata in the split's project.&lt;/li&gt;
&lt;li&gt;Look up the tokens and record keys again, to catch anything a run in flight wrote. Copy any new record keys and run a confirming DELETE. If this found anything new, repeat steps 2 and 3 until a lookup finds nothing new.&lt;/li&gt;
&lt;li&gt;Write the person's tokens to the hand-off table with a pass number. Each registered downstream erasure job (silver, gold, and feature stores) reads the table and writes its confirmation to its own table. The confirmation holds a request ID and pass number only. Without this step, those jobs would have no way to find the person once the mapping is gone.&lt;/li&gt;
&lt;li&gt;Once every registered job has confirmed the current pass, or after a stated timeout that pages someone, delete the mapping and clear the token from the person's retained records. Where the obligation requires the mapping, it moves to the kept list with its basis instead. In that case the retained records keep their token, the minimised view that silver reads returns that token as null, and the mapping never passes through the export view. The hand-off rows go only when every job has confirmed, so a job that timed out can still find the person. They also always go after the retained token is cleared or moved to the kept list, so a merge into silver in between can't bring the token back.&lt;/li&gt;
&lt;li&gt;Wait until step 5 has finished, the erasure time is fixed (as above), and the split has processed every row that landed before it. Then, in one transaction, DELETE the person's raw rows that steps 1 and 3 or the split have already processed, along with the marks of the rows it deletes. After that, delete the queue row. If the erasure time isn't fixed within a stated period of the request, the job pages someone, because the queue row keeps the person's keys until step 6.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 6 finds raw rows four ways: by source key, by customer key on rows dated at or before the erasure, by the record keys in the queue, and by the suppressed record keys carrying the request ID. That catches retained families' raw rows too, once the split has copied them to the retained store. Rows that land after the split's last processed point are left to the split. It routes retained rows to the retained store and marks suppressed ones for the cleanup job.&lt;/p&gt;

&lt;p&gt;The job takes only a request ID as a parameter. It processes only request IDs that appear in intake's own record of verified requests, and it joins its DELETEs on the queue table, as Listing 3 shows. BigQuery reruns a DML statement that conflicts with a concurrent one up to three times (Google Cloud, n.d.ac), and the job retries beyond that. The bronze mark-and-DELETE pair runs as a transaction. BigQuery cancels it on a conflict, so the job retries the whole transaction. Silver's erasure job and the merge into silver both run in transactions, and BigQuery cancels conflicting transactions (Google Cloud, n.d.q), so each retries a cancelled transaction. The erasure log records the request ID, timestamps, row counts, and the computed horizon. It holds no keys or tokens.&lt;/p&gt;

&lt;p&gt;Listing 3 shows two of the DELETEs erasure needs. The bronze DELETE in steps 1 and 3 is keyed by request ID alone, so no key or token appears in its query text. In the same transaction it marks the person's raw rows 'erased', so the split doesn't stage them again and step 6 can find them. Silver's DELETE is keyed by the tokens the erasure job hands off. For a reopen entry, the job runs the same bronze pair, but takes the token from the reopen table for that request in place of the identity lookup. A token found late can belong to a guest whose key was set after the cutoff, and neither branch of the lookup matches such a key. The erasure jobs run as Airflow tasks and pass the request ID and pass number as query parameters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Two scripts: the erasure job's bronze DELETE, and silver's erasure job.&lt;/span&gt;

&lt;span class="c1"&gt;-- Erasure job, steps 1 and 3: delete the person's erased-family events from&lt;/span&gt;
&lt;span class="c1"&gt;-- bronze, and mark the raw rows behind them 'erased' in the same transaction,&lt;/span&gt;
&lt;span class="c1"&gt;-- so the split still counts those raw rows as processed and step 6 can delete&lt;/span&gt;
&lt;span class="c1"&gt;-- them. Raw rows that never reached bronze are left to step 6. The same pair&lt;/span&gt;
&lt;span class="c1"&gt;-- runs on bronze.wishlists and each erased-family table. The only parameter is&lt;/span&gt;
&lt;span class="c1"&gt;-- the request ID, and the keys come from the queue table, so no key or token&lt;/span&gt;
&lt;span class="c1"&gt;-- appears in the query text. Step 1 records cutoff_run before this runs; a&lt;/span&gt;
&lt;span class="c1"&gt;-- NULL cutoff would make the customer-key branch match nothing. For a reopen&lt;/span&gt;
&lt;span class="c1"&gt;-- entry the job runs the same pair with the token taken from the reopen table&lt;/span&gt;
&lt;span class="c1"&gt;-- in place of the identity lookup.&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;split_marks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;marked_rows&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                     &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;split_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'erased'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_map&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;erasure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt;
     &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt;
         &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_set_run&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cutoff_run&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bronze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_map&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;erasure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue_keys&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_key&lt;/span&gt;
     &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_key&lt;/span&gt;
         &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_set_run&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cutoff_run&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Silver's erasure job, after step 4: delete by the hand-off tokens for this&lt;/span&gt;
&lt;span class="c1"&gt;-- request, up to the current pass, and confirm the pass in the same&lt;/span&gt;
&lt;span class="c1"&gt;-- transaction, so a failed DELETE is never confirmed. Silver tables from&lt;/span&gt;
&lt;span class="c1"&gt;-- retained families keep their rows and lose the token, so gold's revenue&lt;/span&gt;
&lt;span class="c1"&gt;-- doesn't move.&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;silver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_items&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;erasure_handoff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pass_number&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;pass_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;silver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;erasure_handoff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pass_number&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;pass_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;erasure_confirm_silver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pass_number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;pass_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 3. The erasure DELETEs in bronze and silver, in BigQuery SQL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The customer-key branch of the bronze DELETE matches only identity rows whose key was set by split runs up to the request's cutoff (the latest run that started before the erasure job's first step). Rows set up to the cutoff can't belong to a returning customer. New tokens for that key wait until the erasure time is fixed, and that never happens before the cutoff is recorded. The edge cases below explain the ordering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Late arrivals and edge cases
&lt;/h3&gt;

&lt;p&gt;These cases are rare, but each one needs a defined path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A token found late.&lt;/strong&gt; If a row arrives before its customer key, the suppression check runs again when the key arrives, under the same date rules, so a returning customer's row isn't reopened. On a match, the split writes the reopen entry before it sets the key on the identity row, so the export view never sees the key unexcluded.&lt;/p&gt;

&lt;p&gt;A match, or any token found after step 3, goes with its request ID to a reopen table kept alongside the suppression entries. The erasure job processes reopen entries whether or not the request is still open. If it's still open, the token joins the request under a new pass number. If step 6 has finished, the job rebuilds the request's queue row from the suppression entries that carry its request ID. If those entries have expired, the reopen pages someone and is handled by hand. Either way, the job deletes the token's events and marks their raw rows, hands the token downstream, deletes its mapping once the pass is confirmed, and logs a new horizon that includes those raw rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Returning customers and the customer-key lookups.&lt;/strong&gt; Each identity row records the split run that set its customer key. The lookups match customer keys only on rows set by runs up to the request's cutoff. Intake's entries were visible before step 1 began, so every later run saw them. Each of those runs suppressed or held back the person's events, and any identity row it created for that key belongs to a returning customer. The cutoff can include a few runs that had already seen the entries. That only widens the search for the person's own rows, because the hold-back rule stops new identity rows for that key until the erasure time is fixed, and the erasure time is never fixed before the cutoff is recorded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rows that land after step 6.&lt;/strong&gt; As we understand it, Datastream reads each change table separately, so the heartbeat shows that capture and landing are moving, but it doesn't prove that every other table has caught up. If a pre-erasure row lands after step 6, the split suppresses and marks it, and the cleanup job deletes it on its next run. Any such row moves the date, because its chain runs from when it landed. It's recorded against the request with a new horizon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backfilled rows.&lt;/strong&gt; Datastream labels backfill events with a &lt;code&gt;read_method&lt;/code&gt; starting &lt;code&gt;sqlserver-backfill&lt;/code&gt; (Google Cloud, n.d.l), and as we understand it they don't carry the original change time. So the split treats backfilled erased-family rows for an erased key as pre-erasure. While customer-key entries live, that also drops a returning customer's new carts and wishlists from a backfill. You can accept that cost, or avoid it by not backfilling during that window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key rotation.&lt;/strong&gt; The lakehouse holds no emails, so it can't re-key anything itself. A rotation of the customer-key secret isn't started while any request is open before step 6, because the suppression entries hold keys computed under the old version and a rotated key on a guest's identity row would no longer match them. The source recomputes its stored customer keys through the key service, and the split updates identity rows from the resulting change rows. It matches a guest's rotated key to the existing token by record key, so the row keeps its token and the erasure job can still find it. The row also keeps the run recorded as having first set the key. An old key version stays enabled until every customer-key suppression entry made under it has expired, because the addresses behind those entries are gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Marketing preferences
&lt;/h2&gt;

&lt;p&gt;Article 21(2) and (3) give people the right to object to direct marketing at any time, after which their data "shall no longer be processed for such purposes" (European Parliament and Council, 2016). In the US, CAN-SPAM bars sending commercial email more than 10 business days after an opt-out request, unless the person later gives affirmative consent (United States Congress, n.d.). So when preferences are erased, a forward-looking do-not-contact marker stays, and we'd record a basis for keeping it. The UK ICO's direct marketing guidance describes a suppression list as keeping just enough information to respect people's preferences in future (Information Commissioner's Office, 2026).&lt;/p&gt;

&lt;p&gt;The marker is an HMAC of the email under a separate suppression key that only the key service can use. It's stored in the key project, where only the key service can read it. The source application asks the key service to create the marker at erasure, and the consent system asks it to remove one when someone gives fresh consent. The sending system asks about an address and gets back allow or suppress. Customer-key rotation doesn't touch the marker, and the suppression key can be rotated without the erased addresses, as the hardening section describes. The marker covers email-keyed channels, and other channels need their own suppression.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger
&lt;/h2&gt;

&lt;p&gt;Figure 1 shows the main stores, and the ledger lists all of them across two tables. The first covers the pipeline's own stores, which the worked example below computes, with backups listed as beyond use. The second covers stores outside the computed date, each with its owner. Some are kept by design, some are owned elsewhere, and silver, gold, and other downstream stores are covered in later parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stores within the horizon (backups listed as beyond use)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where it sits&lt;/th&gt;
&lt;th&gt;How it's removed&lt;/th&gt;
&lt;th&gt;What sets when it's gone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CDC change tables&lt;/td&gt;
&lt;td&gt;Azure SQL Database's CDC scheduler&lt;/td&gt;
&lt;td&gt;Retention (three days by default) and hourly cleanup, plus measured cleanup lag (Microsoft, 2025b)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restricted raw dataset&lt;/td&gt;
&lt;td&gt;Erasure job, step 6; cleanup job for marked and older rows&lt;/td&gt;
&lt;td&gt;Landing, erasure time fixed, split catch-up, job timing, then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity table&lt;/td&gt;
&lt;td&gt;Erasure job, step 5&lt;/td&gt;
&lt;td&gt;Job timing and downstream confirmation, then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token hand-off table&lt;/td&gt;
&lt;td&gt;Erasure job, step 5, once every downstream job confirms&lt;/td&gt;
&lt;td&gt;Job timing and downstream confirmation, then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens in retained records&lt;/td&gt;
&lt;td&gt;Erasure job, step 5 (token cleared)&lt;/td&gt;
&lt;td&gt;Job timing and downstream confirmation, then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bronze events&lt;/td&gt;
&lt;td&gt;Erasure job, steps 1 and 3; bronze retention job for the rest&lt;/td&gt;
&lt;td&gt;Job timing, then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Erasure queue table&lt;/td&gt;
&lt;td&gt;Erasure job, step 6&lt;/td&gt;
&lt;td&gt;The same as the raw dataset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split job staging tables&lt;/td&gt;
&lt;td&gt;Expiry set on each table&lt;/td&gt;
&lt;td&gt;Split processing of the erasure rows, expiry, then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split marks&lt;/td&gt;
&lt;td&gt;Erasure job, step 6, with the raw rows it deletes; cleanup job for the rest, once the marked raw rows are deleted&lt;/td&gt;
&lt;td&gt;Job timing, then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reopen table&lt;/td&gt;
&lt;td&gt;Erasure job, once the reopened request is done&lt;/td&gt;
&lt;td&gt;Job timing, then time travel and fail-safe, for a request that reopens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point-in-time restore backups&lt;/td&gt;
&lt;td&gt;Expire on schedule (beyond use until then)&lt;/td&gt;
&lt;td&gt;Restore window of 1 to 35 days (1 to 7 on Basic), 7 by default; files kept up to a week longer (Microsoft, 2026a)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Stores outside the horizon: kept by design, or owned elsewhere&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where it sits&lt;/th&gt;
&lt;th&gt;Owner and rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source application tables&lt;/td&gt;
&lt;td&gt;The source system's erasure or anonymisation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original all-column change table&lt;/td&gt;
&lt;td&gt;Removed when its capture instance is dropped (Microsoft, 2026g)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-term retention backups&lt;/td&gt;
&lt;td&gt;Up to 10 years, if enabled (Microsoft, 2026f). Can't be deleted while any immutability applies: an unlocked time-based policy has to be disabled first, a locked one keeps each backup until its retention ends, and a legal hold, a preview feature, keeps it until the hold is removed. An immutable backup stays after its retention expires until every immutability is removed (Microsoft, 2026d). Beyond use throughout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source query history and audit logs&lt;/td&gt;
&lt;td&gt;Settings on the source database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intake's record of verified requests&lt;/td&gt;
&lt;td&gt;Kept by design, with its own basis, apart from the ticketing system; links the request ID to the requester&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ticketing&lt;/td&gt;
&lt;td&gt;Kept by design, with its own basis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suppression entries&lt;/td&gt;
&lt;td&gt;Customer-key entries until no pre-erasure event can land; source-key entries while retained events can arrive; record-key entries until no child of that parent can land; then time travel and fail-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Do-not-contact markers&lt;/td&gt;
&lt;td&gt;Until fresh consent; kept by design, with a stated basis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kept list&lt;/td&gt;
&lt;td&gt;Identity rows an obligation requires, for the obligation's period, then time travel and fail-safe; reached only through a named authorised view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downstream confirmation tables&lt;/td&gt;
&lt;td&gt;Request IDs and pass numbers only, kept with the erasure log; linkable to a person only through intake's record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Erasure log&lt;/td&gt;
&lt;td&gt;Kept by design; holds no keys or tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retained store&lt;/td&gt;
&lt;td&gt;The obligation's period, recorded per family&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table snapshots and Iceberg tags&lt;/td&gt;
&lt;td&gt;Google lists table snapshots as unsupported for managed Iceberg tables, so on option B this covers native tables only. Snapshot expiry, set early enough that expiry plus time travel and fail-safe, which we assume apply as for any table, ends by the computed horizon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table clones, materialised views, search indexes, dev copies&lt;/td&gt;
&lt;td&gt;Their owners, or rebuilt after the erasure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query text in &lt;code&gt;INFORMATION_SCHEMA.JOBS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Google's retention for the view, covered in a later part&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit logs and log sinks&lt;/td&gt;
&lt;td&gt;Log bucket retention and each sink's settings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cached and anonymous query results&lt;/td&gt;
&lt;td&gt;BigQuery's own expiry, covered in a later part&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error logs and dead-letter tables&lt;/td&gt;
&lt;td&gt;Retention set per log bucket or table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notebook outputs&lt;/td&gt;
&lt;td&gt;Their owners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profiling and data quality results&lt;/td&gt;
&lt;td&gt;Retention set per tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sensitive Data Protection findings&lt;/td&gt;
&lt;td&gt;Retention set per job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Silver and gold tables&lt;/td&gt;
&lt;td&gt;Downstream erasure jobs reading the hand-off table, then time travel and fail-safe; covered in later parts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exports, extracts, and audiences&lt;/td&gt;
&lt;td&gt;Their owners, with each recipient listed; exported customer keys stay linkable to the person wherever they're kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML training sets and feature stores&lt;/td&gt;
&lt;td&gt;Downstream erasure jobs reading the hand-off table, or their owners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-region replicas&lt;/td&gt;
&lt;td&gt;Replication of the delete, then the replica's own windows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processors and other recipients&lt;/td&gt;
&lt;td&gt;Notified under contract; their contractual deletion terms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few notes apply to particular rows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Time travel and fail-safe.&lt;/strong&gt; BigQuery keeps changed and deleted data for a time travel window of two to seven days, seven by default, and Google says deleted data is then kept for a further seven days of fail-safe that can't be configured or extended (Google Cloud, n.d.i). Google describes fail-safe for deleted or replaced tables and deleted partitions, and doesn't list row-level DML, so for rows removed by DELETE we count it as a conservative upper bound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Option B.&lt;/strong&gt; Data files are garbage-collected after the time travel window (Google Cloud, n.d.b). We take it that deleted files then sit in the bucket's soft delete, since Google recommends keeping that policy for these buckets. Soft delete is seven days by default and configurable from 7 to 90 days, or disabled (Google Cloud, n.d.z). Google doesn't document fail-safe for these tables, so the worked example leaves it out pending confirmation from Google. A dropped option B table leaves its files in the bucket (Google Cloud, n.d.b). So in this design, personal-data tables on option B aren't dropped, including for rebuilds and dev copies, unless the bucket path is cleaned up afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locks.&lt;/strong&gt; A Bucket Lock retention policy stops objects being deleted until they reach the policy's age, and a locked policy can't be removed or shortened (Google Cloud, n.d.d). In this design, personal data stays out of locked buckets. Per Google, option B tables don't support buckets with Bucket Lock at all (Google Cloud, n.d.b).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots.&lt;/strong&gt; Our &lt;a href="https://www.sakurasky.com/blog/qsr-retail-engineering-part-2/" rel="noopener noreferrer"&gt;post on multi-site pipelines&lt;/a&gt; suggested month-end snapshots or tags for finance. Either they expire early enough that their own time travel and fail-safe end by the computed horizon, or they're taken only of gold tables with no personal data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iceberg statistics.&lt;/strong&gt; On option C, and possibly option B (Google doesn't say), Iceberg's default metrics mode stores lower and upper bounds for columns, truncated to 16 for string and binary values (Apache Iceberg, n.d.). By default it covers the first 100 columns, taking top-level fields first and then each nested struct in turn (Apache Iceberg, n.d.). A truncated bound can be enough of a name or delivery address to identify someone. The storage comparison covers the per-column setting that turns this off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries.&lt;/strong&gt; Query text can hold the values someone searched for. Google's AEAD guidance warns that query text may be logged, including any plaintext keyset in it, and recommends parameterised queries (Google Cloud, n.d.a). The erasure job's request-ID design applies the same habit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emails already in bronze.&lt;/strong&gt; Many teams will already have emails in raw and bronze. Removing them means rewriting those tables without the column, or rebuilding them from a keyed copy. Each rewrite starts its own time travel and fail-safe clock, so the horizon for that one-off removal counts from the rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Computing the date
&lt;/h2&gt;

&lt;p&gt;With the stores listed, you can compute the completion date for the pipeline's own stores. Listing 4 models each store as a chain of stages whose waits add up. Some chains start when the request is accepted, and others when the source system erases the record. A chain can also wait for a stage in another chain to end, and then starts at whichever comes later.&lt;/p&gt;

&lt;p&gt;The horizon is the latest end across the chains. The result names the chain that sets it and that chain's longest adjustable stage, which is usually the place to start. Fixed windows, such as fail-safe, can't be that stage. When chains tie, the one listed first is named, so list them in a deliberate order. Backups get their own list, because each expires on its own schedule and needs re-erasure if it's restored. Records kept by design or obligation are listed with their retention rule and basis, so the date doesn't read as the moment every trace is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections.abc&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;REQUEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# clock starts when the erasure request is accepted
&lt;/span&gt;    &lt;span class="n"&gt;SOURCE_ERASURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# clock starts when the source system erases the record
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;              &lt;span class="c1"&gt;# worst-case wait before data moves to the next stage
&lt;/span&gt;    &lt;span class="n"&gt;adjustable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;      &lt;span class="c1"&gt;# False for fixed windows such as BigQuery fail-safe
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Start&lt;/span&gt;
    &lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;    &lt;span class="c1"&gt;# waits add up along a chain
&lt;/span&gt;    &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# (store, stage): also wait for that stage to end
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Backup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;         &lt;span class="c1"&gt;# until the last backup taken before source erasure is gone
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Kept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;retention_rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;basis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                   &lt;span class="c1"&gt;# by design, legal obligation, or fixed retention
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Horizon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;deleted_by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;         &lt;span class="c1"&gt;# live stores: the latest chain end
&lt;/span&gt;    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                   &lt;span class="c1"&gt;# the chain that sets the date
&lt;/span&gt;    &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;            &lt;span class="c1"&gt;# that chain's longest adjustable stage (first on ties)
&lt;/span&gt;    &lt;span class="n"&gt;beyond_use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
    &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Kept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_stage_ends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_erased&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;by_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store names must be unique&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dependency cycle at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REQUEST&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;source_erased&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="c1"&gt;# start at the latest of the clock and each dependency
&lt;/span&gt;            &lt;span class="n"&gt;dep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; waits for unknown stage &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;negative wait in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;discard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;horizon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source_erased&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;backups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Backup&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Kept&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Horizon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcoffset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;source_erased&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcoffset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;times must be timezone-aware&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;need at least one chain, each with at least one stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stage names must be unique within a chain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retention&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;backups&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;negative backup retention&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stage_ends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_stage_ends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_erased&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;stage_ends&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# first chain wins on ties
&lt;/span&gt;    &lt;span class="n"&gt;lever&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;# first maximum wins, as in the Rust version
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adjustable&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lever&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lever&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;lever&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
    &lt;span class="n"&gt;beyond_use&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_erased&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;backups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Horizon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lever&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lever&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beyond_use&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rust&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;chrono&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeDelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Start&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// clock starts when the erasure request is accepted&lt;/span&gt;
    &lt;span class="n"&gt;SourceErasure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// clock starts when the source system erases the record&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Stage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeDelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// worst-case wait before data moves to the next stage&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;adjustable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// false for fixed windows such as BigQuery fail-safe&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Stage&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// waits add up along a chain&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// (store, stage): also wait for that stage to end&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Backup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeDelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// until the last backup taken before source erasure is gone&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Kept&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;retention_rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;basis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// by design, legal obligation, or fixed retention&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Horizon&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;deleted_by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// live stores: the latest chain end&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// the chain that sets the date&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// that chain's longest adjustable stage (first on ties)&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;beyond_use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Kept&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Ends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;clocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;Ends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dependency cycle at {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.start&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;clocks&lt;/span&gt;&lt;span class="na"&gt;.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SourceErasure&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;clocks&lt;/span&gt;&lt;span class="na"&gt;.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// start at the latest of the clock and each dependency&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="py"&gt;.stages&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.position&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{} waits for unknown stage {store}: {stage}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="nf"&gt;.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;with_capacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.stages&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.stages&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="py"&gt;.wait&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nn"&gt;TimeDelta&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"negative wait in {}: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="py"&gt;.wait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="nf"&gt;.pop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Taking `DateTime&amp;lt;Utc&amp;gt;` means a time without a zone can't be passed in.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;horizon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source_erased&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;backups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Backup&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Kept&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Horizon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="py"&gt;.stages&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"need at least one chain, each with at least one stage"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="py"&gt;.stages&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="py"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="py"&gt;.stages&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"stage names must be unique within a chain"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;backups&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.any&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.retention&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nn"&gt;TimeDelta&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"negative backup retention"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"store names must be unique"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Ends&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chains&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;stage_ends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;by_store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_erased&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;visiting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;stage_ends&lt;/span&gt;&lt;span class="nf"&gt;.last&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"stages is not empty"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deleted_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chains is not empty"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// first maximum wins, as in the Python version (max_by_key would keep the last)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lever&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;
        &lt;span class="py"&gt;.stages&lt;/span&gt;
        &lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.adjustable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;None&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Stage&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.wait&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.wait&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Horizon&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;deleted_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="py"&gt;.store&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lever&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;beyond_use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;backups&lt;/span&gt;
            &lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;source_erased&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="py"&gt;.retention&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 4. Computing the horizon from chains of stages. Waits add along a chain, a chain can wait for a stage in another, and the horizon is the latest chain end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The table runs the example estate through Listing 4. The erasure job runs daily, split runs time out after six hours, BigQuery's time travel window is at its default, rows take about an hour to land in the raw dataset, and the source system erases the record two days after the request. Microsoft gives no SLA for when changes reach the change tables (Microsoft, 2025b), so the landing lag and the change tables' cleanup lag are measured figures, watched against thresholds. The days count from the request.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chain&lt;/th&gt;
&lt;th&gt;Starts at&lt;/th&gt;
&lt;th&gt;Stages&lt;/th&gt;
&lt;th&gt;Ends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bronze events (native)&lt;/td&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;Job cadence 1 day, split-run timeout 6 hours, time travel 7 days, fail-safe 7 days&lt;/td&gt;
&lt;td&gt;Day 15, plus 6 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bronze events (option B variant)&lt;/td&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;Job cadence 1 day, split-run timeout 6 hours, time travel 7 days, garbage-collection lag (measured), soft delete 7 days (fail-safe left out pending Google)&lt;/td&gt;
&lt;td&gt;Day 15, plus 6 hours and the measured garbage-collection lag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity table, hand-off table, and retained-record tokens&lt;/td&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;Job cadence 1 day, split-run timeout 6 hours, downstream confirmation timeout 1 day, time travel 7 days, fail-safe 7 days&lt;/td&gt;
&lt;td&gt;Day 16, plus 6 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw landing (timing only)&lt;/td&gt;
&lt;td&gt;Source erasure (day 2)&lt;/td&gt;
&lt;td&gt;Landing 1 hour, erasure time fixed on the next job run 1 day, split catch-up 12 hours (a run already under way, then one more)&lt;/td&gt;
&lt;td&gt;Day 3, plus 13 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restricted raw dataset&lt;/td&gt;
&lt;td&gt;The later of raw landing (day 3, plus 13 hours) and the end of step 5 (day 2, plus 6 hours)&lt;/td&gt;
&lt;td&gt;Job cadence 1 day, time travel 7 days, fail-safe 7 days&lt;/td&gt;
&lt;td&gt;Day 18, plus 13 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Erasure queue table&lt;/td&gt;
&lt;td&gt;The same as the raw dataset&lt;/td&gt;
&lt;td&gt;The same stages as the raw dataset&lt;/td&gt;
&lt;td&gt;Day 18, plus 13 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split staging tables&lt;/td&gt;
&lt;td&gt;The end of raw landing (day 3, plus 13 hours)&lt;/td&gt;
&lt;td&gt;Table expiry 1 day, time travel 7 days, fail-safe 7 days&lt;/td&gt;
&lt;td&gt;Day 18, plus 13 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDC change tables&lt;/td&gt;
&lt;td&gt;Source erasure (day 2)&lt;/td&gt;
&lt;td&gt;Retention 3 days, cleanup interval 1 hour, cleanup lag (measured)&lt;/td&gt;
&lt;td&gt;Day 5, plus 1 hour and the measured cleanup lag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point-in-time restore backups&lt;/td&gt;
&lt;td&gt;Source erasure (day 2)&lt;/td&gt;
&lt;td&gt;Restore window 7 days, files up to 7 days more, listed as beyond use&lt;/td&gt;
&lt;td&gt;Restorable until day 9, files gone by day 16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here the raw dataset, the queue table, and the split staging tables tie and set the horizon. The raw dataset is named because it comes first, and time travel is its longest adjustable stage. The staging tables are there because split runs still process the person's erasure rows after the source erasure. With a two-day table expiry, they would set the horizon on their own.&lt;/p&gt;

&lt;p&gt;Step 6 can't run until the split has processed the landed erasure rows and step 5 has finished, so the raw and queue chains start at the later of those two points. The raw landing row is there only to time the first. The erasure rows land, the next daily sweep fixes the erasure time, and then the split processes what it was holding back. The split runs continuously, with each run starting as the last ends. The stages are summed as independent upper bounds, so the table overcounts by up to the split catch-up whenever that finishes before the next sweep, as it does here.&lt;/p&gt;

&lt;p&gt;A longer downstream confirmation timeout pushes both chains later: with three days, the horizon moves to day 19, plus 6 hours. Listing 4 names a stage only from the chain that sets the date. When a dependency drives the date, as a long confirmation timeout would, that dependency's stage is the better place to start.&lt;/p&gt;

&lt;p&gt;Some waits fall outside the table. The hand-off table's date holds only if every downstream job confirms within the timeout. After a timeout, its rows stay until the late job confirms, and the request's horizon is logged as provisional until then. A slow cancellation can stretch even the first round's wait to the margin past the timeout, which adds the margin to the bronze and identity chains. The table takes it as zero. A halted cleanup job holds raw rows the same way a halted split does, so its delay is added to the raw chain. Each extra round of steps 2 and 3 adds a split-run timeout, and its margin if cancellation is slow, to the bronze and identity chains, and to raw when step 5 is its later start. A halted split job holds up step 6 and moves the date for every open request.&lt;/p&gt;

&lt;p&gt;Shortening the time travel window to the two-day minimum, where the recovery trade-off allows, moves every native BigQuery chain five days earlier. A shorter window also shortens how far back you can recover from a mistake, which a later part on recovery takes up.&lt;/p&gt;

&lt;p&gt;Getting the source system to erase sooner helps only while raw landing is the later start. Here raw landing ends a day and seven hours after step 5, so an earlier source erasure gains up to that much before the confirmation timeout and job cadence become the levers. For the same reason, fixing the erasure time as soon as the rows land, rather than on the next daily run, would take a day off whenever raw landing is the later start, as here.&lt;/p&gt;

&lt;p&gt;A soft delete period at the 90-day maximum would make option B set the horizon.&lt;/p&gt;

&lt;p&gt;Our tests check the horizon in the worked example, the effect of a longer confirmation timeout, and three properties on randomised chains without dependencies: a longer wait never brings the date forward, removing a chain never pushes it back, and a later source erasure never brings it forward. Both suites also run the same tie case. The Python needs version 3.10 or later, and the Rust uses chrono.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening the design
&lt;/h2&gt;

&lt;p&gt;The sections above lean on access controls at almost every step. This section gathers them in one place. They're written for the example estate, and a later part on access goes further into Entra ID and BigQuery.&lt;/p&gt;

&lt;h3&gt;
  
  
  The key service and its keys
&lt;/h3&gt;

&lt;p&gt;Only the key service can use the customer-key secret, under three conditions. Its rate limits, and logging without the addresses, are rules in its own code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No person holds basic roles, Cloud KMS Admin, or IAM admin roles on the key's project or above it as standing access, and any elevation is just-in-time with approval. Basic roles don't separate managing keys from using them, and Cloud KMS Admin can set IAM policy on keys and key rings (Google Cloud, n.d.s), so either lets its holder grant themselves use of the key.&lt;/li&gt;
&lt;li&gt;The one automation account that manages the key and deploys the key service works only through changes that pass two-person review. No person can impersonate it, create or upload keys for it, or run its pipeline outside those changes.&lt;/li&gt;
&lt;li&gt;No person can impersonate the key service's service account, create keys for it, or deploy code that runs as it. Service account key creation and key upload are blocked by organisation policy, and nobody holds Organization Policy Administrator as standing access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same conditions apply to the suppression key, and the third applies to the batch endpoint's service account under the fallback, which the deployment account deploys. The first also applies to every isolated project, every project that holds a job or intake service account, the Airflow project, and the projects holding the deployment and infrastructure accounts, at that level and above. The last applies to every job and intake service account in this post.&lt;/p&gt;

&lt;h3&gt;
  
  
  The source database and Datastream's login
&lt;/h3&gt;

&lt;p&gt;Creating a capture instance needs db_owner (Microsoft, 2026h), and Google's setup for Azure SQL Database adds Datastream's own login to db_owner and db_denydatawriter, with a password (Google Cloud, n.d.g). That login, and the connection profile holding its password, can create an all-column capture instance or read every column. Members of db_owner can manage fixed database role membership (Microsoft, 2026e), so we'd assume the login can also remove itself from db_denydatawriter.&lt;/p&gt;

&lt;p&gt;Google's example enables CDC with no gating role (Google Cloud, n.d.g). Naming one limits which other users can read change data. It doesn't restrict db_owner members, including Datastream's own login. db_owner members (or sysadmin on SQL Server) can read changes outright; anyone else needs column permissions and, where one is set, the gating role (Microsoft, 2025a).&lt;/p&gt;

&lt;p&gt;So automation generates the password and stores it in Secret Manager. A SQL Server connection profile can reference a secret in place of a password (Google Cloud, n.d.t), and Google's guide grants the Secret Manager Secret Accessor role to the Datastream service account (Google Cloud, n.d.ae), which we'd grant on that one secret only. The guide doesn't show the reference for Azure SQL Database specifically, so it's one of the checks in the testing section. The secret sits in the raw project. No person knows the password, and no person holds secret-access roles on that secret or its project as standing access. The automation account that sets it uses a SQL principal that can alter Datastream's login. That makes it admin-equivalent on the source, so it works only through reviewed changes, can't be impersonated, and can't read secret versions after writing them.&lt;/p&gt;

&lt;p&gt;Alerts on that login come from auditing configured at the Azure resource level, outside the database because, as we understand it, a db_owner member could drop a trigger inside it. The SQL Security Manager role can change server and database audit settings (Microsoft, 2026b), and Owner and Contributor can manage all resources (Microsoft, 2026c). SQL Server Contributor's actions include &lt;code&gt;Microsoft.Sql/servers/*&lt;/code&gt;. As we read them, those cover the server's administrators, though the role's excluded actions keep it from changing audit settings (Microsoft, 2026b). As we understand it, the server's admin login and its Microsoft Entra admin can do everything Datastream's login can.&lt;/p&gt;

&lt;p&gt;So the key project's first condition applies here too. No person holds the admin login, the Entra admin, any of those Azure roles, User Access Administrator, or Role Based Access Control Administrator (Microsoft, 2026c) as standing access, and elevation is just-in-time. If the Entra admin is a group, it has no standing members or owners. The Entra directory roles that could change that group are a matter for the later part on access. Deletion in the audit destination is restricted in the same way. Restoring a backup is also a server-level action, so the same list, with SQL DB Contributor added, is what keeps backups beyond use. A database restore raises an alert.&lt;/p&gt;

&lt;h3&gt;
  
  
  The isolation rule
&lt;/h3&gt;

&lt;p&gt;The raw dataset, split staging, the split's marks table, the identity table, the suppression entries, the erasure queue, the reopen and token hand-off tables, the downstream confirmation tables, bronze, the retained store, intake's record of verified requests, the kept list, and every authorised view's dataset follow one isolation rule. The queue, the suppression entries, the hand-off table, intake's record, and the kept list are on that list because each amounts to a list of erased or kept people. The do-not-contact markers sit in the key project, under its conditions. The retained store is there because it holds identifying fields the obligations require.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Creation.&lt;/strong&gt; Each dataset is created by an infrastructure-as-code service account that sets the access list at creation, so no person picks up a creator's OWNER access. That account can create, change, and grant access to tables, but a custom role leaves it without read or write access to the data, so reaching the data through it needs an access change. Impersonating it and running its pipeline are restricted, and merges to its repository need two-person review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projects.&lt;/strong&gt; Each sits in a project where no principal other than the named job service accounts and the infrastructure account holds BigQuery or basic roles at or above the project. A scanner that reads these datasets, such as Sensitive Data Protection, is named in the access list like any other job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The raw project.&lt;/strong&gt; Raw sits in its own project, apart from the identity and suppression datasets, holding only the raw dataset and the Datastream secret. The Datastream API is enabled only there, and the Restrict Resource Service Usage organisation policy constraint, which supports Datastream (Google Cloud, n.d.x), blocks it elsewhere. That matters because each project's Datastream agent gets BigQuery access in its own project. Datastream's permissions for writing to BigQuery come from the Datastream Service Agent role (Google Cloud, n.d.f), and the isolation rule gives that agent no access in the other projects. A role held at project level applies to every dataset in the project. So the agent is the only exception there, and split staging and the remote function's dataset live in the split's own project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streams and connection profiles.&lt;/strong&gt; The connection profile logs in as a db_owner member, so anyone who can create or edit a stream can send every column, the email included, to a dataset of their choosing. Those permissions follow the key project's first condition: no standing access, and just-in-time elevation only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment.&lt;/strong&gt; One deployment account deploys the job code. It can run code as the jobs it deploys, so it effectively has their data access, though it holds no table-data permission itself. It's held under the same rules: two-person review, no impersonation, no key creation or upload, and a pipeline that runs only from reviewed merges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views.&lt;/strong&gt; Every view authorised on an isolated dataset, for bronze, the retained store, the identity table, or the export job, has its SQL kept in infrastructure as code with review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Option B buckets.&lt;/strong&gt; On option B the data files sit in Cloud Storage, where Storage roles reach them without any BigQuery permission. Google has the table's connection service account hold Storage Object User and Storage Legacy Bucket Reader on the bucket, and recommends restricting write and delete permissions for most users (Google Cloud, n.d.b). We'd go further: only that service account holds object access, and restoring soft-deleted objects is held as tightly. Each Iceberg connection sits in the isolated project of the tables it serves, and permission to create tables through it is held by the infrastructure account alone, so nobody can define a second table over the bucket's files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Job roles, and what IAM can and can't enforce
&lt;/h3&gt;

&lt;p&gt;The job accounts get custom roles: the table-data permission where they write, read access only where they read, and no permission to create, delete, or reconfigure tables. The roles are granted at dataset level for raw and split staging, whose tables are created at run time, and at table level elsewhere. The split job is the one exception to the table rules, with table creation in its staging dataset. The gold view in Listing 1 is created once by the infrastructure account, and no scheduled job recreates it.&lt;/p&gt;

&lt;p&gt;Airflow is an execution path, so it falls under the deployment rule. Each job, the split, silver, any gold tables, the erasure job, silver's erasure job, and the cleanup and retention jobs, runs as Airflow tasks under its own service account, and no task runs with a person's own credentials.&lt;/p&gt;

&lt;p&gt;Airflow's environment service account can act as every job account, and impersonates each job's account for that job's tasks. So it's held like the deployment account: no person can impersonate it or create or upload keys for it, and only the deployment account writes to the environment's DAG bucket or updates the environment. It is the only principal that can act as job accounts, and it holds no BigQuery role. The Airflow roles that can trigger DAGs or edit connections are just-in-time, and so are the Google Cloud permissions that reach the same account another way: running Airflow commands through the environment's API, and any access to the cluster that runs its workers.&lt;/p&gt;

&lt;p&gt;If you already run your SQL in Dataform or dbt, the same rules apply: one service account per job, no run under a person's own credentials, and only the deployment account changing what runs. In Dataform that means a repository and custom service account per job. Under its strict act-as mode, anyone who creates or updates a repository or workflow configuration, creates a workflow invocation, or updates a release configuration needs permission to act as the effective service account, and the Dataform service agent needs the Service Account User and Token Creator roles on each job account (Google Cloud, n.d.af).&lt;/p&gt;

&lt;p&gt;The Dataform service agent then takes the environment account's place as the principal that can act as job accounts, and changes to repositories or configurations are alerted like environment updates. With dbt, we'd give each job its own target and service account and keep the profiles with the deployment account, and the runner's own service account takes that place.&lt;/p&gt;

&lt;p&gt;We'd assume, pending a test, that one table-data permission covers inserts, updates, and deletes alike. So "only the split job inserts bronze rows" rests on who holds that permission, plus alerts on which statements each account runs. The table below lists them. The retention, bronze retention, and cleanup jobs need read access, permission to run jobs, and the table-data permission on their tables, because a DELETE runs as a query job and reads the rows it matches.&lt;/p&gt;

&lt;p&gt;Request intake's role holds the table-data permission on its own tables, the queue, the suppression entries, and its record of verified requests, and nothing else. With no permission to run jobs anywhere, it can, as we understand it, append through the Storage Write API and can't read, load, copy, or run DML. Those appends go to a stream rather than a query job, so there's no query text or job parameter to hold a key. The Storage Write API's change mode, which can delete, requires a primary key on the destination table (Google Cloud, n.d.ab), so none of intake's tables declares one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reads during the time travel window
&lt;/h3&gt;

&lt;p&gt;Reading bronze during the time travel window is also reading rows the erasure job has deleted. As we understand it, a plain query with &lt;code&gt;FOR SYSTEM_TIME AS OF&lt;/code&gt; needs only read access and permission to run jobs. Snapshotting a table's past state needs &lt;code&gt;bigquery.tables.createSnapshot&lt;/code&gt; among the source permissions, with BigQuery Data Viewer listed among the roles that qualify, plus permission to create tables in a destination dataset (Google Cloud, n.d.h).&lt;/p&gt;

&lt;p&gt;So analysts and silver jobs read bronze through authorised views and hold no read access on the tables themselves. Principals with direct table access, including the jobs named in this post, can still read deleted rows until the time travel window ends.&lt;/p&gt;

&lt;h3&gt;
  
  
  The export view
&lt;/h3&gt;

&lt;p&gt;The export view returns token and customer key only. It compares split runs, the same clock as Listing 2. It leaves out an identity row whose source key has a suppression entry, and one with a matching customer-key entry if the entry has no erasure time yet, or if the row's key was set by a run no later than the one recorded when the erasure time was fixed. It also leaves out any token in the reopen table. A returning customer's later rows are included; marketing consent is checked separately. Because the view reads the suppression entries, it's also authorised on their dataset, and uses them only to exclude. The export job can read the view and nothing else in that dataset.&lt;/p&gt;

&lt;h3&gt;
  
  
  The remote-function fallback
&lt;/h3&gt;

&lt;p&gt;Calling a remote function needs &lt;code&gt;bigquery.routines.get&lt;/code&gt; on its dataset and the BigQuery Connection User role (Google Cloud, n.d.ah), granted to the split job's service account on that one connection. BigQuery calls the endpoint through the connection, in batches, and each request carries documented &lt;code&gt;sessionUser&lt;/code&gt; and &lt;code&gt;caller&lt;/code&gt; fields (Google Cloud, n.d.ah).&lt;/p&gt;

&lt;p&gt;Those fields sit in the request body, so the endpoint can trust them only once it knows the caller. The batch endpoint runs as its own Cloud Run service whose only invoker is the connection's service account, and it forwards each batch to the key service, so it never uses the key itself. It checks that &lt;code&gt;sessionUser&lt;/code&gt; is the split job's service account, and caps rows per job with headroom for retries.&lt;/p&gt;

&lt;h3&gt;
  
  
  The marker key
&lt;/h3&gt;

&lt;p&gt;The suppression key can be rotated without the erased addresses. Cloud KMS rotation leaves earlier versions active (Google Cloud, n.d.n). Automatic rotation is supported only for keys with the ENCRYPT_DECRYPT purpose, and other keys have no primary version (Google Cloud, n.d.u), so this HMAC key is rotated by hand and the key service names the version it signs with. New markers use the new version, and the check computes the HMAC under every enabled version, writing a new-version marker when it matches an old one.&lt;/p&gt;

&lt;p&gt;Checks alone won't move every marker, because an address that's never checked keeps its old-version marker. So re-marking runs in bulk, as its own named caller with its own rate limit and a log of each run: every address on the sending system's list goes through the check after a rotation, and again before any list import. The key service can't check that a caller already holds an address it asks about, so each caller's rate limit and logging are what limit it.&lt;/p&gt;

&lt;p&gt;Google advises disabling a compromised version and revoking access to it as soon as possible (Google Cloud, n.d.n). Disabling this one would stop checks against existing opt-outs. IAM can't be set on a key version (Google Cloud, n.d.s), so limiting the old version to checks is a rule in the key service's code. On compromise, revoke the misused access first, then run the bulk re-marking straight away.&lt;/p&gt;

&lt;p&gt;Markers for addresses on no list can't be moved that way. Email addresses are guessable, so a leaked key plus the marker store would show which of them opted out. The choice is then between keeping the old version enabled for checks, with that exposure, and disabling it and losing those opt-outs, and whichever you choose gets recorded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alerted events
&lt;/h3&gt;

&lt;p&gt;Detection backs every rule above that IAM can't enforce on its own. Data Access audit logs are disabled by default for every service except some BigQuery services (Google Cloud, n.d.k), so they're enabled for Secret Manager on the project that holds the Datastream secret.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Where it's seen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IAM policy change on a key, key ring, the Datastream secret, the Iceberg connection, isolated project, folder, or the organisation; on an isolated table; on the option B bucket; on the key service's or batch endpoint's Cloud Run service; or on a job, intake, key-service, batch-endpoint, or deployment service account; any change to organisation policy&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Datastream stream or connection profile created or changed&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change to a dataset access list, or to an authorised view's definition&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DML, load, or copy job on an isolated table from a principal outside its named writers; table deletion, settings change, or truncating write&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A named writer running the wrong statement: an insert into bronze from anyone but the split job, or into raw from anyone but Datastream's service agent; an insert into the retained store from anyone but the split job; a delete from bronze by anyone but the erasure and bronze retention jobs; a delete from the identity table by anyone but the erasure job, or an insert by anyone but the split job; a delete from the retained store by anyone but the retention job, or an update by anyone but the erasure job; an update or delete in raw by anyone but the erasure and cleanup jobs; an insert into the split's marks table by anyone but the split and erasure jobs, or a delete by anyone but the erasure and cleanup jobs; an insert into &lt;code&gt;ops.committed_split_runs&lt;/code&gt; by anyone but the split job; an update or delete on the suppression entries by anyone but the erasure job; an insert or append into the suppression entries, queue, or intake's record by anyone but their named writers; any update to bronze; an update to the identity table by anyone but the split and erasure jobs&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs, for job-based writes; Storage Write API appends depend on one of the checks in the testing section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A primary key added to any of intake's tables&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An update to the Airflow environment; an Airflow command run through the environment's API; a grant of Service Account User or Token Creator on a job account&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A write to the Airflow environment's DAG bucket&lt;/td&gt;
&lt;td&gt;Cloud Storage Data Access logs, enabled for that bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A change to Airflow connections or variables&lt;/td&gt;
&lt;td&gt;Airflow's own audit log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A table created in an isolated dataset, or through an Iceberg connection, by anyone but the infrastructure account, the split job in its staging dataset, or Datastream in raw&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A query with &lt;code&gt;FOR SYSTEM_TIME AS OF&lt;/code&gt;, or a snapshot, clone, or copy job, reading bronze, raw, the identity table, the retained store, the suppression entries, the queue, or the hand-off table&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A read of the Datastream password secret by any principal other than Datastream's service agent&lt;/td&gt;
&lt;td&gt;Secret Manager Data Access logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new version of that secret&lt;/td&gt;
&lt;td&gt;Cloud Audit Logs (Admin Activity)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new capture instance on a captured table, or a change to database role membership&lt;/td&gt;
&lt;td&gt;Azure SQL auditing at resource level, with the relevant audit action groups enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A change to audit settings, the admin password, or the Entra admin, or a new database on the server, which is how a restore appears&lt;/td&gt;
&lt;td&gt;Azure Activity Log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A change to the Entra admin group's members or owners&lt;/td&gt;
&lt;td&gt;Microsoft Entra audit logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A password change on Datastream's login&lt;/td&gt;
&lt;td&gt;Azure SQL auditing at resource level, with the relevant audit action groups enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An email-like column in raw, a halted split, a hold past its threshold, landing lag past its threshold, or a downstream confirmation timeout&lt;/td&gt;
&lt;td&gt;Pipeline monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What a data engineer still has to test
&lt;/h2&gt;

&lt;p&gt;Google's and Microsoft's documentation, and an AI agent working from it, can take you a long way with this design. What neither can determine is how the services behave where the documentation is unclear or covers a point only in part, and whether this design's own scripts behave as intended. That part is the data engineer's job: run each check below against real services in a non-production project before anyone relies on the design. The checks fall into five groups.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How Datastream behaves on this source.&lt;/strong&gt; Confirm that the stream's included-objects column list governs backfills as well as CDC, and that DELETE rows carry the deleted row's values. Check that the source timestamp on SQL Server change rows is the transaction's commit time, and find which Datastream field orders changes that share a timestamp (carried into bronze as &lt;code&gt;change_seq&lt;/code&gt; in Listing 2). See how backfilled rows can be told apart in BigQuery and how their timestamps are set. Confirm that Datastream reads change tables independently, so one table landing doesn't prove another has, and that rows it writes over gRPC can be changed by DML straight away, which depends on it using that path. Check that partition expiration behaves as set on the tables it creates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the access boundaries hold.&lt;/strong&gt; Test that an Azure SQL Database connection profile accepts a Secret Manager password reference, with Secret Accessor granted to the Datastream service account on that secret. Point a stream at the identity or suppression projects and expect it to fail, and expect enabling the Datastream API outside the raw project to be refused. Confirm that Datastream's login can remove itself from db_denydatawriter. Check whether one table-data permission covers insert, update, and delete in BigQuery. Check that intake's role allows nothing beyond Storage Write API appends, and what audit logs record for those appends on every isolated table. Confirm that a plain &lt;code&gt;FOR SYSTEM_TIME AS OF&lt;/code&gt; query needs nothing beyond read access and permission to run jobs, and that time travel can't be applied through an authorised view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the jobs run the way the design assumes.&lt;/strong&gt; Check that Airflow's Google provider runs each script as a BigQuery job under the job account it impersonates, with the run number passed as a query parameter, and find the narrowest grant on the environment service account that allows it. Confirm that &lt;code&gt;INFORMATION_SCHEMA.JOBS&lt;/code&gt; shows a running split script, by the account Airflow impersonates, within a bounded delay. A job timeout on a BigQuery script should end the whole script, cancelling a timed-out Airflow task should stop its script, and the view should show it done before Airflow's retry starts. Measure how often the erasure job's bronze transaction cancels a concurrent split run's transaction, which shows up as a failed split run and a retry. Run Listings 1 to 3, with their variables substituted, as a BigQuery dry run against real tables, and confirm they pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens to deleted data afterwards.&lt;/strong&gt; Confirm that an expired table snapshot goes through time travel and fail-safe like any table. Fail-safe for option B tables is a question for Google.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The split's rules and its race with an erasure.&lt;/strong&gt; Test a child arriving before its parent, a customer key arriving after its row, and the DELETE row after source erasure. Then test the races: a long split run started before step 1's record-key insert, one that creates a new identity row, a suppression append that lands after the queue row, an erasure time that would be fixed before step 1, and a key rotation requested while an erasure is open.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Proving it, and the clocks that apply
&lt;/h2&gt;

&lt;p&gt;The EDPB's report recommends that controllers map personal data and its storage locations. It also describes, as good practice, systems that generate records automatically as proof of deletion (European Data Protection Board, 2026). The ledger gives the map, and the erasure log, checked against the horizon, gives the evidence. Since BigQuery doesn't record the automatic deletion of expired partitions in its audit logs (Google Cloud, n.d.p), the erasure job and the raw cleanup job write their own records.&lt;/p&gt;

&lt;p&gt;Several clocks apply, and they measure different things. Article 17(1) requires erasure "without undue delay", and Article 12(3) separately requires the controller to tell the person what action was taken within one month of the request, extendable by two further months where necessary (European Parliament and Council, 2016). Under section 1798.130(a)(2)(A), the CCPA gives businesses 45 days to respond to a verifiable request, extendable once by a further 45 days when reasonably necessary, if the consumer is told within the first 45 days (California Legislature, n.d.c). CAN-SPAM's 10 business days apply to email opt-outs (United States Congress, n.d.). Other US state privacy laws set their own deadlines, which this post doesn't cover.&lt;/p&gt;

&lt;p&gt;The computed horizon is about when recoverable copies are gone, so we'd report it alongside those clocks. In this part it covers the pipeline's own stores only, which makes it a lower bound for the platform. We wouldn't give it to the person as a completion date until the silver, gold, and other downstream chains are added, which later parts do.&lt;/p&gt;

&lt;p&gt;Backups are handled differently. The UK ICO's guidance, which the ICO notes is under review, asks that backup data be put "beyond use" even if it can't be overwritten immediately (Information Commissioner's Office, n.d.). The California regulations let a business delay deletion from archived or backup systems until that system is restored to an active system or next accessed or used for a sale, disclosure, or commercial purpose (California Privacy Protection Agency, n.d.b).&lt;/p&gt;

&lt;p&gt;For backups, the EDPB report recommends that controllers verify and be able to demonstrate that erasure has been carried out. It found that many controllers had no specific procedure for backups, and that some relied on automatic deletion measures or on retention periods applied to backups (European Data Protection Board, 2026). A beyond-use list with a re-erasure rule on restore gives that procedure a concrete form, and the response to the person can say that backups are kept beyond use until they expire.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for the next request
&lt;/h2&gt;

&lt;p&gt;For a token-based design like this one, these are worth having in place before the next erasure request arrives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Event families sorted into erased and retained, with the retained ones' basis written down.&lt;/li&gt;
&lt;li&gt;The email kept out of capture and out of backfills, with the capture instance created before the stream.&lt;/li&gt;
&lt;li&gt;Raw rows deleted by the erasure job, a cleanup job for the rest, and expiration as a backstop.&lt;/li&gt;
&lt;li&gt;The restricted stores under one isolation rule, with per-entry retention for suppression, and do-not-contact markers under their own key.&lt;/li&gt;
&lt;li&gt;The erasure job's order: suppress at the request; then delete, wait, look up again until nothing new, hand tokens downstream, delete the mapping once downstream jobs confirm, and clear raw once the erasure time is fixed and processed.&lt;/li&gt;
&lt;li&gt;A ledger row for every store, each with an owner, and a named request-intake identity that alone records verified requests, with the erasure job acting only on those.&lt;/li&gt;
&lt;li&gt;A computed horizon and a beyond-use list for every request, recorded in the erasure log.&lt;/li&gt;
&lt;li&gt;The data engineer's checks run in a test project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful first exercise is to take one of last quarter's erasure requests, run it through the ledger, and check that every row has an owner.&lt;/p&gt;

&lt;p&gt;Our &lt;a href="https://www.sakurasky.com/data/" rel="noopener noreferrer"&gt;Data &amp;amp; AI practice&lt;/a&gt; is glad to go through a ledger like this with your platform team.&lt;/p&gt;

&lt;p&gt;This post describes engineering approaches. It isn't legal advice, and which obligations apply to a particular business is a question for its counsel.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Apache Iceberg, n.d. &lt;em&gt;Configuration.&lt;/em&gt; Apache Iceberg documentation. Available at: &lt;a href="https://iceberg.apache.org/docs/latest/configuration/" rel="noopener noreferrer"&gt;https://iceberg.apache.org/docs/latest/configuration/&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;California Legislature, n.d.a. &lt;em&gt;Civil Code section 1798.100.&lt;/em&gt; California Legislative Information. Available at: &lt;a href="https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.100" rel="noopener noreferrer"&gt;https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.100&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;California Legislature, n.d.b. &lt;em&gt;Civil Code section 1798.105: Consumers' Right to Delete Personal Information.&lt;/em&gt; Text as published by Justia, 2025 California Code. Available at: &lt;a href="https://law.justia.com/codes/california/code-civ/division-3/part-4/title-1-81-5/section-1798-105/" rel="noopener noreferrer"&gt;https://law.justia.com/codes/california/code-civ/division-3/part-4/title-1-81-5/section-1798-105/&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;California Legislature, n.d.c. &lt;em&gt;Civil Code section 1798.130: Notice, Disclosure, Correction, and Deletion Requirements.&lt;/em&gt; Text as published by Justia, 2025 California Code. Available at: &lt;a href="https://law.justia.com/codes/california/code-civ/division-3/part-4/title-1-81-5/section-1798-130/" rel="noopener noreferrer"&gt;https://law.justia.com/codes/california/code-civ/division-3/part-4/title-1-81-5/section-1798-130/&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;California Legislature, n.d.d. &lt;em&gt;Civil Code section 1798.140.&lt;/em&gt; California Legislative Information. Available at: &lt;a href="https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.140" rel="noopener noreferrer"&gt;https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.140&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;California Privacy Protection Agency, n.d.a. &lt;em&gt;Cal. Code Regs. tit. 11, § 7012: Notice at Collection of Personal Information.&lt;/em&gt; Text as published by the Legal Information Institute, Cornell Law School. Available at: &lt;a href="https://www.law.cornell.edu/regulations/california/11-CCR-7012" rel="noopener noreferrer"&gt;https://www.law.cornell.edu/regulations/california/11-CCR-7012&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;California Privacy Protection Agency, n.d.b. &lt;em&gt;Cal. Code Regs. tit. 11, § 7022: Requests to Delete.&lt;/em&gt; Text as published by the Legal Information Institute, Cornell Law School. Available at: &lt;a href="https://www.law.cornell.edu/regulations/california/11-CCR-7022" rel="noopener noreferrer"&gt;https://www.law.cornell.edu/regulations/california/11-CCR-7022&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;European Data Protection Board, 2026. &lt;em&gt;2025 Coordinated Enforcement Action: Implementation of the right to erasure by controllers.&lt;/em&gt; Adopted 10 February 2026. Available at: &lt;a href="https://www.edpb.europa.eu/system/files/2026-02/edpb_cef-report_2025_right-to-erasure_en.pdf" rel="noopener noreferrer"&gt;https://www.edpb.europa.eu/system/files/2026-02/edpb_cef-report_2025_right-to-erasure_en.pdf&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;European Parliament and Council, 2016. &lt;em&gt;Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data, and repealing Directive 95/46/EC (General Data Protection Regulation).&lt;/em&gt; Official Journal of the European Union, L 119, 4 May, pp. 1-88. Available at: &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2016/679/oj&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.a. &lt;em&gt;AEAD encryption concepts.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/aead-encryption-concepts" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/aead-encryption-concepts&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.b. &lt;em&gt;Apache Iceberg managed tables.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/biglake-iceberg-tables-in-bigquery" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/biglake-iceberg-tables-in-bigquery&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.c. &lt;em&gt;Basic roles and permissions.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/access-control-basic-roles" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/access-control-basic-roles&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.d. &lt;em&gt;Bucket Lock.&lt;/em&gt; Cloud Storage documentation. Available at: &lt;a href="https://docs.cloud.google.com/storage/docs/bucket-lock" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/storage/docs/bucket-lock&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.e. &lt;em&gt;Column-level encryption with Cloud KMS.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/column-key-encrypt" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/column-key-encrypt&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.f. &lt;em&gt;Configure a BigQuery destination.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/configure-bigquery-destination" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/configure-bigquery-destination&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.g. &lt;em&gt;Configure an Azure SQL database for CDC.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/configure-azure-sqlserver" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/configure-azure-sqlserver&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.h. &lt;em&gt;Create table snapshots.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/table-snapshots-create" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/table-snapshots-create&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.i. &lt;em&gt;Data retention with time travel and fail-safe.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/time-travel" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/time-travel&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.j. &lt;em&gt;Destroy and restore key versions.&lt;/em&gt; Cloud Key Management Service documentation. Available at: &lt;a href="https://docs.cloud.google.com/kms/docs/destroy-restore" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/kms/docs/destroy-restore&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.k. &lt;em&gt;Enable Data Access audit logs.&lt;/em&gt; Cloud Logging documentation. Available at: &lt;a href="https://docs.cloud.google.com/logging/docs/audit/configure-data-access" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/logging/docs/audit/configure-data-access&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.l. &lt;em&gt;Events and streams.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/events-and-streams" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/events-and-streams&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.m. &lt;em&gt;JOBS view.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/information-schema-jobs" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/information-schema-jobs&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.n. &lt;em&gt;Key rotation.&lt;/em&gt; Cloud Key Management Service documentation. Available at: &lt;a href="https://docs.cloud.google.com/kms/docs/key-rotation" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/kms/docs/key-rotation&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.o. &lt;em&gt;Manage backfill for the objects of a stream.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/manage-backfill-for-the-objects-of-a-stream" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/manage-backfill-for-the-objects-of-a-stream&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.p. &lt;em&gt;Manage partitioned tables.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/managing-partitioned-tables" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/managing-partitioned-tables&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.q. &lt;em&gt;Multi-statement transactions.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/transactions" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/transactions&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.r. &lt;em&gt;Partition and cluster BigQuery tables.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/partitioning-and-clustering" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/partitioning-and-clustering&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.s. &lt;em&gt;Permissions and roles.&lt;/em&gt; Cloud Key Management Service documentation. Available at: &lt;a href="https://docs.cloud.google.com/kms/docs/reference/permissions-and-roles" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/kms/docs/reference/permissions-and-roles&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.t. &lt;em&gt;REST Resource: projects.locations.connectionProfiles.&lt;/em&gt; Datastream API reference. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/reference/rest/v1/projects.locations.connectionProfiles" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/reference/rest/v1/projects.locations.connectionProfiles&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.u. &lt;em&gt;REST Resource: projects.locations.keyRings.cryptoKeys.&lt;/em&gt; Cloud Key Management Service API reference. Available at: &lt;a href="https://docs.cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.v. &lt;em&gt;REST Resource: projects.locations.streams.&lt;/em&gt; Datastream API reference. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/reference/rest/v1/projects.locations.streams" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/reference/rest/v1/projects.locations.streams&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.w. &lt;em&gt;Run parameterized queries.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/parameterized-queries" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/parameterized-queries&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.x. &lt;em&gt;Services that support restricting service usage.&lt;/em&gt; Organization Policy documentation. Available at: &lt;a href="https://docs.cloud.google.com/resource-manager/docs/organization-policy/restricting-resources-supported-services" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/resource-manager/docs/organization-policy/restricting-resources-supported-services&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.y. &lt;em&gt;Set up the Apache Iceberg REST catalog endpoint.&lt;/em&gt; Lakehouse documentation. Available at: &lt;a href="https://docs.cloud.google.com/lakehouse/docs/set-up-lakehouse-iceberg-rest-catalog" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/lakehouse/docs/set-up-lakehouse-iceberg-rest-catalog&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.z. &lt;em&gt;Soft delete overview.&lt;/em&gt; Cloud Storage documentation. Available at: &lt;a href="https://docs.cloud.google.com/storage/docs/soft-delete" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/storage/docs/soft-delete&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.aa. &lt;em&gt;Stream data from SQL Server databases.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/sources-sqlserver" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/sources-sqlserver&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.ab. &lt;em&gt;Stream table updates with change data capture ingestion.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/change-data-capture" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/change-data-capture&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.ac. &lt;em&gt;Transform data with data manipulation language (DML).&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/data-manipulation-language" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/data-manipulation-language&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.ad. &lt;em&gt;Update dataset properties.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/updating-datasets" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/updating-datasets&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.ae. &lt;em&gt;Use Secret Manager to store sensitive data.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/use-secret-manager" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/use-secret-manager&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.af. &lt;em&gt;Use strict act-as mode.&lt;/em&gt; Dataform documentation. Available at: &lt;a href="https://docs.cloud.google.com/dataform/docs/strict-act-as-mode" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/dataform/docs/strict-act-as-mode&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.ag. &lt;em&gt;What is medallion architecture?&lt;/em&gt; Google Cloud. Available at: &lt;a href="https://cloud.google.com/discover/what-is-medallion-architecture" rel="noopener noreferrer"&gt;https://cloud.google.com/discover/what-is-medallion-architecture&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.ah. &lt;em&gt;Work with remote functions.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/remote-functions" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/remote-functions&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Information Commissioner's Office, 2026. &lt;em&gt;Respect people's preferences.&lt;/em&gt; Direct marketing guidance. Available at: &lt;a href="https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/direct-marketing-guidance/respect-peoples-preferences/" rel="noopener noreferrer"&gt;https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/direct-marketing-guidance/respect-peoples-preferences/&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Information Commissioner's Office, n.d. &lt;em&gt;Right to erasure.&lt;/em&gt; UK GDPR guidance and resources. Available at: &lt;a href="https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/individual-rights/individual-rights/right-to-erasure/" rel="noopener noreferrer"&gt;https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/individual-rights/individual-rights/right-to-erasure/&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2025a. &lt;em&gt;cdc.fn_cdc_get_all_changes_ (Transact-SQL).&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/cdc-fn-cdc-get-all-changes-capture-instance-transact-sql" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/cdc-fn-cdc-get-all-changes-capture-instance-transact-sql&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2025b. &lt;em&gt;Change data capture (CDC) with Azure SQL Database.&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/azure/azure-sql/database/change-data-capture-overview?view=azuresql" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/azure/azure-sql/database/change-data-capture-overview?view=azuresql&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026a. &lt;em&gt;Automated backups in Azure SQL Database.&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/azure/azure-sql/database/automated-backups-overview?view=azuresql" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/azure/azure-sql/database/automated-backups-overview?view=azuresql&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026b. &lt;em&gt;Azure built-in roles for Databases.&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/databases" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/databases&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026c. &lt;em&gt;Azure built-in roles for Privileged.&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026d. &lt;em&gt;Backup immutability for long-term retention backups in Azure SQL Database.&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/azure/azure-sql/database/backup-immutability" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/azure/azure-sql/database/backup-immutability&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026e. &lt;em&gt;Database-level roles.&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026f. &lt;em&gt;Long-term retention backups: Azure SQL Database and Azure SQL Managed Instance.&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/azure/azure-sql/database/long-term-retention-overview?view=azuresql" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/azure/azure-sql/database/long-term-retention-overview?view=azuresql&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026g. &lt;em&gt;sys.sp_cdc_disable_table (Transact-SQL).&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sys-sp-cdc-disable-table-transact-sql?view=sql-server-ver17" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sys-sp-cdc-disable-table-transact-sql?view=sql-server-ver17&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2026h. &lt;em&gt;sys.sp_cdc_enable_table (Transact-SQL).&lt;/em&gt; Microsoft Learn. Available at: &lt;a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sys-sp-cdc-enable-table-transact-sql?view=sql-server-ver17" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sys-sp-cdc-enable-table-transact-sql?view=sql-server-ver17&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;National Institute of Standards and Technology, 2025. &lt;em&gt;Guidelines for Media Sanitization (NIST SP 800-88 Rev. 2).&lt;/em&gt; NIST Special Publication 800-88 Revision 2, September 2025. Available at: &lt;a href="https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-88r2.pdf" rel="noopener noreferrer"&gt;https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-88r2.pdf&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;United States Congress, n.d. &lt;em&gt;15 U.S. Code § 7704: Other protections for users of commercial electronic mail.&lt;/em&gt; Text as published by the Legal Information Institute, Cornell Law School. Available at: &lt;a href="https://www.law.cornell.edu/uscode/text/15/7704" rel="noopener noreferrer"&gt;https://www.law.cornell.edu/uscode/text/15/7704&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

</description>
      <category>data</category>
      <category>gcp</category>
      <category>privacy</category>
      <category>compliance</category>
    </item>
    <item>
      <title>Sakura Sky and UpCloud: A European Cloud Partnership</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:53:24 +0000</pubDate>
      <link>https://dev.to/sakurasky/sakura-sky-and-upcloud-a-european-cloud-partnership-294j</link>
      <guid>https://dev.to/sakurasky/sakura-sky-and-upcloud-a-european-cloud-partnership-294j</guid>
      <description>&lt;p&gt;If your team builds for customers in the UK and Europe, where your platform runs is increasingly part of what you sell. Your customers ask where their data lives, who can reach it, and what it will cost to grow. I am delighted to say that Sakura Sky is now an UpCloud partner, which means we can answer those questions with a European provider and a single contract.&lt;/p&gt;

&lt;p&gt;For a long time, when a customer asked us for a European option, the answer was a European region of a hyperscaler. Those regions work well, and we will keep building on them. What I have wanted is a European provider we could recommend with the same conviction, with flat pricing and in-house engineers behind its support. UpCloud is that provider for us. From today we resell UpCloud, and our teams are ready to design, build, and run platforms on it for organisations across the UK and the Benelux.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why UpCloud
&lt;/h2&gt;

&lt;p&gt;UpCloud was formed in 2011 and is headquartered in Helsinki (UpCloud, 2026a). UpCloud's legal team states that all of its European data centres are operated by the Finnish parent company, UpCloud Oy, under the relevant Finnish and EU member state laws, and that the group's US subsidiary has no control over customer account information or over data held by the parent outside the US, and so cannot be compelled to disclose it under the US CLOUD Act (UpCloud Legal Team, 2026). Each organisation will want its own advice, but for many teams that makes UpCloud a practical step towards sovereign cloud.&lt;/p&gt;

&lt;p&gt;UpCloud offers a 99.999% uptime SLA, holds ISO 27001 certification, and describes its infrastructure as GDPR-compliant. Support is 24/7 and run by UpCloud's own people, and UpCloud quotes under 40 seconds to reach a human expert (UpCloud, 2026b). It runs fifteen data centres, ten of them in Europe, including London and Amsterdam (UpCloud, 2026c), which puts infrastructure close to the customers we work with most.&lt;/p&gt;

&lt;p&gt;Here is what UpCloud offers that we think matters most, and what we will be testing as we build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing you can plan around.&lt;/strong&gt; Flat-rate pricing (UpCloud, 2026b), with outbound transfer included under UpCloud's Fair Transfer Policy: the allowance scales with your cloud spend. There are no excess fees by default; if you go over, UpCloud may limit bandwidth for the rest of the month, or you can opt in to metered transfer above the allowance (UpCloud, 2026d). That lets data-heavy products treat egress as a planned line in the budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast storage and fast starts.&lt;/strong&gt; Premium plans include UpCloud's MaxIOPS block storage by default, which UpCloud lists at 100k IOPS (UpCloud, 2026j), and UpCloud says new servers are running in under 45 seconds (UpCloud, 2026b). We will test both in our open benchmarks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed services where you need them.&lt;/strong&gt; Managed Kubernetes is available in every UpCloud location, with a free development tier and a highly available production tier (UpCloud, 2026e; UpCloud, 2026f). Managed Databases for PostgreSQL run in every location (UpCloud, 2026g), with the pgvector extension available for retrieval workloads (Piwosz, 2026), and UpCloud describes Managed Object Storage as fully compatible with the S3 API (UpCloud, 2026h).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPUs for open models.&lt;/strong&gt; GPU Servers with NVIDIA L4, L40S, RTX PRO 6000, H100, and B200 cards, currently offered from Helsinki (UpCloud, 2026i), give us a European home for running open-weight models on dedicated GPUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built to stay portable.&lt;/strong&gt; UpCloud describes its pricing as open and reversible (UpCloud, 2026b), which matches how we build: infrastructure as code on open interfaces, so you can move if your needs change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this brings our customers
&lt;/h2&gt;

&lt;p&gt;Sakura Sky is headquartered in Amsterdam, so this is a European partnership on both sides. Where we resell UpCloud, the platform and our engineering come under one contract and one monthly bill. You work with a team that knows your platform well, with UpCloud's own support team behind the infrastructure. On UpCloud, compute and managed databases can run in London or Amsterdam (UpCloud, 2026c), and object storage runs in Helsinki, Stockholm, or Frankfurt (UpCloud, 2026h), so you can tell your customers or your regulator where each part of your data lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are launching
&lt;/h2&gt;

&lt;p&gt;Alongside the partnership, we are introducing four services built on UpCloud:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;European Landing Zone.&lt;/strong&gt; An UpCloud foundation delivered as code, covering account and sub-account structure, networking, access control, security baselines, and observability, so your first production workloads go live on a foundation that is already reviewed and documented.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed European Platform.&lt;/strong&gt; Your platform on UpCloud, run by us: Kubernetes, managed databases, object storage, monitoring, patching, and cost management, with the same operating discipline our &lt;a href="https://www.sakurasky.com/managed-services/" rel="noopener noreferrer"&gt;Managed Services&lt;/a&gt; customers already rely on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.sakurasky.com/managed-open-models/" rel="noopener noreferrer"&gt;Managed Open Models&lt;/a&gt;.&lt;/strong&gt; Open-weight models chosen for your workload and served on dedicated GPU capacity in UpCloud's Helsinki data centre (UpCloud, 2026i), with the retrieval layer, gateway, spend controls, and upgrades run by us. By default, inference runs on GPU capacity in Finland, inside your environment, and prompts are not sent to a third-party model API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit Readiness Assessment.&lt;/strong&gt; A practical review of how portable your workloads are across providers, wherever you run today, with a plan to make future moves simpler and cheaper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our &lt;a href="https://www.sakurasky.com/professional-services/" rel="noopener noreferrer"&gt;Professional Services&lt;/a&gt; teams can also work alongside your engineers on migrations, architecture, and platform engineering on UpCloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where UpCloud works well
&lt;/h2&gt;

&lt;p&gt;If your cloud bill is hard to forecast, data residency and third-party risk keep coming up in your GDPR, NIS2, or DORA work, or your team spends more time running Kubernetes than building on it, UpCloud is worth a look. These are the workloads we expect to bring to it most often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data and AI.&lt;/strong&gt; GPU servers in Helsinki and fast storage for training and inference, in a European location you can name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup and disaster recovery.&lt;/strong&gt; Off-site copies and a standby environment in another European country, on compute that is affordable to keep warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulated industries and the public sector.&lt;/strong&gt; Fintech, healthtech, and public sector services that need European jurisdiction and an environment they can audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second cloud alongside a hyperscaler.&lt;/strong&gt; A European home for the workloads that have to stay in-region, next to an existing AWS, Google Cloud, or Azure estate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving off VMware or on-premises.&lt;/strong&gt; Lift-and-shift where that is the right first step, or modernisation onto Kubernetes and managed services where the business case supports it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sharing what we learn
&lt;/h2&gt;

&lt;p&gt;We will be publishing a good deal of engineering work on UpCloud over the coming months: build-along guides, a series on running open models in production, field notes from migrations, and cloud benchmarks with an open methodology and harness that anyone can rerun. We will publish the results and the raw data together, so you can check our working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Come and meet us
&lt;/h2&gt;

&lt;p&gt;We are starting user groups in the Netherlands and the UK for people running, or thinking about running, workloads on UpCloud. Both groups will meet through Meetup, and we will announce the first sessions here on the blog. We are also planning monthly online office hours, where you can bring an architecture or a migration plan and talk it through with our engineers.&lt;/p&gt;

&lt;p&gt;My thanks to the UpCloud team for the work they have put into getting us here. If you are weighing up European cloud for your next platform, or wondering whether it suits what you run today, &lt;a href="https://www.sakurasky.com/contact/" rel="noopener noreferrer"&gt;I would be glad to talk&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: Sakura Sky is an UpCloud reseller and partner. Our engineering series and benchmarks are written independently: UpCloud does not pay for or direct them, and does not review or approve them before publication. Managed Open Models and the other services named here are delivered by Sakura Sky.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Piwosz, P., 2026. &lt;em&gt;Installing pgvector on UpCloud Managed PostgreSQL.&lt;/em&gt; UpCloud. Available at: &lt;a href="https://upcloud.com/resources/tutorials/installing-pgvector-upcloud-managed-postgresql/" rel="noopener noreferrer"&gt;https://upcloud.com/resources/tutorials/installing-pgvector-upcloud-managed-postgresql/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026a. &lt;em&gt;About UpCloud – European Cloud Provider.&lt;/em&gt; Available at: &lt;a href="https://upcloud.com/about/" rel="noopener noreferrer"&gt;https://upcloud.com/about/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026b. &lt;em&gt;Global Cloud Platform.&lt;/em&gt; Available at: &lt;a href="https://upcloud.com/" rel="noopener noreferrer"&gt;https://upcloud.com/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026c. &lt;em&gt;UpCloud Data Center Information.&lt;/em&gt; Available at: &lt;a href="https://upcloud.com/data-centres/" rel="noopener noreferrer"&gt;https://upcloud.com/data-centres/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026d. &lt;em&gt;Fair Transfer Policy: Zero-cost Egress.&lt;/em&gt; Available at: &lt;a href="https://upcloud.com/fair-transfer-policy" rel="noopener noreferrer"&gt;https://upcloud.com/fair-transfer-policy&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026e. &lt;em&gt;Managed Kubernetes availability.&lt;/em&gt; UpCloud Documentation. Available at: &lt;a href="https://upcloud.com/docs/products/managed-kubernetes/availability/" rel="noopener noreferrer"&gt;https://upcloud.com/docs/products/managed-kubernetes/availability/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026f. &lt;em&gt;UpCloud Managed Kubernetes Platform.&lt;/em&gt; Available at: &lt;a href="https://upcloud.com/products/managed-kubernetes/" rel="noopener noreferrer"&gt;https://upcloud.com/products/managed-kubernetes/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026g. &lt;em&gt;Managed PostgreSQL availability.&lt;/em&gt; UpCloud Documentation. Available at: &lt;a href="https://upcloud.com/docs/products/managed-postgresql/availability/" rel="noopener noreferrer"&gt;https://upcloud.com/docs/products/managed-postgresql/availability/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026h. &lt;em&gt;The Perfect Home For Your Data: Managed Object Storage.&lt;/em&gt; Available at: &lt;a href="https://upcloud.com/products/object-storage/" rel="noopener noreferrer"&gt;https://upcloud.com/products/object-storage/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026i. &lt;em&gt;GPU Servers availability.&lt;/em&gt; UpCloud Documentation. Available at: &lt;a href="https://upcloud.com/docs/products/gpu-servers/availability/" rel="noopener noreferrer"&gt;https://upcloud.com/docs/products/gpu-servers/availability/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud, 2026j. &lt;em&gt;Premium Cloud Servers.&lt;/em&gt; Available at: &lt;a href="https://upcloud.com/solutions/premium-cloud-servers/" rel="noopener noreferrer"&gt;https://upcloud.com/solutions/premium-cloud-servers/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

&lt;p&gt;UpCloud Legal Team, 2026. &lt;em&gt;U.S. CLOUD Act and Why Corporate Structure Matters for Data Sovereignty.&lt;/em&gt; UpCloud, 22 January. Available at: &lt;a href="https://upcloud.com/blog/us-cloud-act-corporate-structure-matters-data-sovereignty/" rel="noopener noreferrer"&gt;https://upcloud.com/blog/us-cloud-act-corporate-structure-matters-data-sovereignty/&lt;/a&gt; [Accessed 12 October 2026].&lt;/p&gt;

</description>
      <category>upcloud</category>
      <category>cloud</category>
      <category>sovereignty</category>
    </item>
    <item>
      <title>Multi-Cloud Retail Without the Mess</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Fri, 09 Oct 2026 11:04:52 +0000</pubDate>
      <link>https://dev.to/sakurasky/multi-cloud-retail-without-the-mess-47k6</link>
      <guid>https://dev.to/sakurasky/multi-cloud-retail-without-the-mess-47k6</guid>
      <description>&lt;p&gt;Many multi-brand QSR and retail estates span clouds in a recognisable way. Staff sign in with Microsoft Entra ID, some back-office systems run on SQL Server in Azure, the data platform is on Google Cloud, and a loyalty vendor or an acquired brand brings AWS. If your estate looks like that, it probably isn't anyone's plan. Each piece was a sensible decision when it was made. The cost shows up later, in the connections between them: service account keys copied into another cloud's secret store, the same customer held under three different IDs, and nightly copies of the same tables in two places.&lt;/p&gt;

&lt;p&gt;Our earlier post &lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-5/" rel="noopener noreferrer"&gt;Multi-Cloud Versus Consolidation&lt;/a&gt; made the cross-industry case that most organisations are multi-cloud whether or not they chose to be, and that the work is to make the estate coherent. This post applies that to retail and QSR. It looks at how these estates come to span three clouds, where the extra engineering falls, and the identity and data layer that holds them together, with a diagram of the layer, the token exchange behind it, and code for the customer key at its centre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Joining one customer across three clouds
&lt;/h2&gt;

&lt;p&gt;Take an estate where the catering and gift card systems run on SQL Server in Azure, and a back-office sync job moves their changes into BigQuery on Google Cloud, which is the data platform for sales, operations, and marketing. The loyalty programme runs on a SaaS platform, with an integration in the retailer's AWS account that syncs member records, and a history export that the vendor writes to an S3 bucket in the same account. Delivery orders arrive as webhooks from each aggregator at an integration service on Google Cloud, which injects them into the POS and forwards them to the data platform. In our experience, what aggregators pass on about the customer varies by aggregator and by market, and some orders carry no usable email at all, while others carry a masked relay address that won't match anything else. Marketing wants to send a customer an offer, so the data platform needs to know that a catering order, a loyalty member, and a delivery order with the same email address belong to the same person, and to treat delivery orders without one as unmatched, flagging relay-address keys so they don't inflate customer counts.&lt;/p&gt;

&lt;p&gt;Three teams own the three parts of that question, and each has solved its own part well. The back office stores the email typed in at checkout. The loyalty platform has a member ID. The delivery integration stores the aggregator's customer email, when the order carries one. Joining them means moving email addresses, or full customer tables, between clouds, and each team has a reasonable fix that makes the overall picture a little harder to manage.&lt;/p&gt;

&lt;h2&gt;
  
  
  How estates end up on three clouds
&lt;/h2&gt;

&lt;p&gt;Most of the clouds in an estate like this arrive with a product or a business decision. Microsoft 365 often brings Entra ID with it, and Entra ID becomes the place staff identities live. Back-office applications built on SQL Server often move to Azure, a short step from the data centre they ran in. A data team picks Google Cloud for the data platform on technical grounds. A SaaS loyalty platform runs on whichever cloud its vendor chose, and its exports tend to land nearby. An acquired brand brings its own cloud accounts, its own POS integration, and a team that knows them.&lt;/p&gt;

&lt;p&gt;Franchise networks add another layer, because franchisees may run systems the franchisor doesn't control and send data in whatever form the franchise agreement provides for.&lt;/p&gt;

&lt;p&gt;Most of the cost comes from the default way of connecting the pieces. A workload on one cloud that needs something on another usually gets a long-lived credential, such as a service account key or an access key, stored in its own cloud's secret manager. Each team identifies customers its own way, so matching them means exporting email addresses or customer tables to wherever the match runs. And data needed in two clouds tends to get copied on a schedule, so there are two copies to secure, two to keep current, and a transfer bill each time it moves.&lt;/p&gt;

&lt;p&gt;Sometimes the right answer is consolidation. Sakura Sky moved &lt;a href="https://www.sakurasky.com/case-studies/our-work-with-craveable-brands-featured-as-a-google-cloud-platform-case-study/" rel="noopener noreferrer"&gt;Craveable Brands'&lt;/a&gt; data platform to Google Cloud, bringing data from across its brands and outlets into one BigQuery warehouse. Where consolidation doesn't pay, because the loyalty vendor's cloud isn't yours to choose or the SQL Server estate works well where it is, the job is to make the connections between clouds deliberate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the cross-cloud work falls
&lt;/h2&gt;

&lt;p&gt;For a retail estate, most of that work falls in three places.&lt;/p&gt;

&lt;p&gt;The first is machine identity. Services in one cloud need to call services and read data in another without anyone storing a long-lived key for the other cloud. AWS, Google Cloud, and Microsoft Entra ID all document ways to exchange one platform's credentials for another's short-lived tokens (AWS, n.d.a; Google Cloud, n.d.a; Microsoft, 2025), so the stored key can usually be retired.&lt;/p&gt;

&lt;p&gt;The second is customer identity. Every system that holds customer records needs to agree on who a customer is, and it helps if they can do that without each one holding the others' raw contact details. That usually means one pseudonymous customer key, computed by one service.&lt;/p&gt;

&lt;p&gt;The third is data placement. Each dataset of record lives in one place, with one owner, and other clouds read it where it is. Copies are made for a reason, such as latency, data residency, or resilience, and each copy is recorded with its source and refresh schedule.&lt;/p&gt;

&lt;p&gt;Two smaller disciplines sit alongside them. Events crossing clouds, such as aggregator orders, use one envelope format, so each consumer doesn't write its own parser for each source. And consent travels with the customer key: an opt-out recorded in the loyalty platform has to reach the data platform before the next campaign runs, which &lt;a href="https://www.sakurasky.com/blog/qsr-retail-engineering-part-1/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; covered from the loyalty side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The identity and data layer
&lt;/h2&gt;

&lt;p&gt;Figure 1 shows one way the pieces fit together for the estate above, with Google Cloud as the data platform and the home of the customer key service. In your estate the clouds may play different roles, with AWS as the data platform, for example, or the loyalty platform on Azure, and the same pattern should carry over, though the services and their limits differ.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-09-qsr-multicloud-identity-data.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-09-qsr-multicloud-identity-data.png" title="Figure 1. The identity and data layer across three clouds." alt="Figure 1. An example three-cloud retail estate, from Azure at the top to AWS at the bottom. A back-office sync job on Azure uses its managed identity to load SQL Server changes into BigQuery. The sync job, the loyalty integration on AWS, and the delivery integration on Google Cloud get customer keys from one key service, the first two authenticating through federation. Aggregator orders reach BigQuery as CloudEvents, and the loyalty history in S3 is read in place." width="800" height="1552"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Machine identity with fewer stored keys
&lt;/h3&gt;

&lt;p&gt;Google Cloud's Workload Identity Federation lets workloads on Azure and AWS exchange their own environment's credentials for short-lived Google Cloud credentials, so they can call Google Cloud APIs without a service account key (Google Cloud, n.d.a). Figure 2 shows the Azure case. The back-office sync job runs on an Azure VM with a managed identity. It asks the Azure Instance Metadata Service for an access token for a Microsoft Entra ID application set up for federation, identified by its Application ID URI. In Google Cloud, a provider in a workload identity pool takes the tenant's issuer as its issuer URL and that Application ID URI as its allowed audience. Google's example issuer is &lt;code&gt;https://sts.windows.net/&amp;lt;tenant-id&amp;gt;&lt;/code&gt;, but the value can vary, so it's copied from the &lt;code&gt;iss&lt;/code&gt; claim of a real token. The token's subject is the managed identity's object ID, which the provider maps to the federated identity's subject, so IAM roles can be granted to that one identity, and an attribute condition on the provider can reject tokens from any other identity in the tenant (Google Cloud, n.d.a). Google Cloud's Security Token Service then exchanges the Entra token for a short-lived Google Cloud access token, referred to here as a federated token. BigQuery's API has no known limitations with federated identities (Google Cloud, n.d.g), so the sync job can be granted access to its datasets directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-09-qsr-federated-token-exchange.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-09-qsr-federated-token-exchange.png" title="Figure 2. Federated token exchange from Azure to Google Cloud." alt="Figure 2. A back-office sync job on an Azure VM loads data into BigQuery without a stored Google credential. Microsoft Entra ID issues the VM's managed identity a token for the federation app, and Google Cloud's Security Token Service exchanges it for a short-lived Google Cloud access token." width="800" height="998"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The AWS side of Figure 1 works the same way with different credentials. One of the options Google documents for AWS uses the workload's ordinary temporary AWS credentials, which Google Cloud verifies through AWS's GetCallerIdentity API with no configuration changes in the AWS account. The other uses AWS outbound identity federation, where AWS acts as the identity provider and its workloads request short-lived JSON Web Tokens to present to external services such as Google Cloud (AWS, n.d.b; Google Cloud, n.d.a). Google recommends granting the federated identity access to resources directly, and falling back to service account impersonation only for products that don't support that (Google Cloud, n.d.a).&lt;/p&gt;

&lt;p&gt;Calls in the other direction follow the same pattern. Entra ID's workload identity federation lets an app registration or user-assigned managed identity trust tokens from an external provider, with Google Cloud and AWS among the documented scenarios (Microsoft, 2025). AWS's AssumeRoleWithWebIdentity returns temporary AWS credentials, by default for one hour, in exchange for a token from an OpenID Connect provider such as Google, provided the role's trust policy names that provider (AWS, n.d.a). We'd also restrict the subject and the audience in the trust policy, because naming only the provider trusts every identity it issues tokens for. For Google tokens there's a trap: the &lt;code&gt;accounts.google.com:aud&lt;/code&gt; condition key takes the token's &lt;code&gt;azp&lt;/code&gt; claim when that claim is set, and the token's &lt;code&gt;aud&lt;/code&gt; is then checked with &lt;code&gt;accounts.google.com:oaud&lt;/code&gt; instead (AWS, n.d.c).&lt;/p&gt;

&lt;p&gt;What changes in practice is where trust is configured. Instead of a key in a secret store, each cloud holds a trust relationship naming the other cloud's identity provider, plus conditions on which workloads it accepts. Those relationships need the same care a key did. Revoking access happens on the receiving side, because a token that's already been issued typically stays valid until it expires. In Google Cloud, that means removing the role granted on the resource itself, such as access to the BigQuery dataset or the Cloud Run invoker role held by the impersonated service account, because IAM checks it on each call. Disabling the provider stops new token exchanges, but tokens already issued run until they expire. In AWS, revoking the role's active sessions denies credentials issued before that point (AWS, n.d.d). A provider that accepts any token from an Entra tenant, or any identity in an AWS account, trusts far more workloads than the ones that need access, so its attribute condition names only the specific managed identities or AWS roles that do.&lt;/p&gt;

&lt;p&gt;For people, the Entra ID that head-office staff already use can also sign analysts and engineers in to Google Cloud through Workforce Identity Federation, without copying their accounts into Google (Google Cloud, n.d.d), though not every Google Cloud product supports federated identities in the same way (Google Cloud, n.d.g).&lt;/p&gt;

&lt;h3&gt;
  
  
  One customer key, computed by one service
&lt;/h3&gt;

&lt;p&gt;The customer key in Figure 1 is a pseudonymous join key: a value derived from a customer's normalised email address that every system can store and join on in place of the address itself. Listing 1 computes it with HMAC-SHA256 and a secret key. A plain hash such as SHA-256 of the email isn't enough, because anyone with a list of email addresses can hash them and compare. With HMAC, customer keys can only be produced by whoever holds the secret key.&lt;/p&gt;

&lt;p&gt;That's also why the key service runs in one place. If the secret key were copied into every cloud so each could compute customer keys locally, it would become exactly the kind of shared secret federation removes. Instead, the back-office sync job and the loyalty integration authenticate to the key service through federation, the delivery integration calls it with its own Google Cloud identity, and each sends an email address and stores the customer key it returns. If the key service runs on Cloud Run, the federated callers can't be granted access directly, because Cloud Run doesn't support direct resource access for federated identities (Google Cloud, n.d.g). Instead, they use their federated token to impersonate a service account that may invoke the service, and request an ID token as that account, which Cloud Run requires (Google Cloud, n.d.a). Addresses still cross clouds, but only to one authenticated endpoint that doesn't store or log them, in place of whole customer tables copied to wherever the match runs.&lt;/p&gt;

&lt;p&gt;The secret itself can live in Cloud KMS, which supports HMAC keys for MAC signing (Google Cloud, n.d.e), so the service calls KMS to compute each HMAC; Listing 1 takes the key as a parameter to keep the example self-contained. Any workload allowed to call the service can still check whether an address matches a stored customer key, so access is limited to named workloads, rate-limited, and logged without the addresses. For the same reason, only the key service's own identity should hold permission to sign and verify with the KMS key, because anyone who can compute or check the HMAC can test addresses against stored keys. Each key also costs a network round trip and a KMS request, so order and enrolment flows don't wait on it: a record can be stored first and keyed asynchronously, and bulk jobs such as backfills and re-keying go through a batch endpoint, where the extra time and cost are easiest to absorb. Campaign audiences leave the data platform as customer keys, and the system that holds the address, such as the loyalty platform or the email service, resolves them for sending.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;

&lt;span class="n"&gt;_EDGE_WHITESPACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t\r\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# trimmed identically in every implementation
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_ascii_lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# bytes.lower() changes only A-Z, so non-ASCII characters are left as-is
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalise_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_EDGE_WHITESPACE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not an email address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Policy choice: lowercase the local part too (see RFC 5321, section 2.4)
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_ascii_lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_ascii_lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;customer_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Pseudonymous join key: the same email always gives the same key.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key must be at least 32 bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key_version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rust&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mac&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;sha2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Sha256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;unicode_normalization&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnicodeNormalization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;HmacSha256&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Hmac&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Sha256&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// trimmed identically in every implementation&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_edge_whitespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;' '&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'\t'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'\r'&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="sc"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;normalise_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="nf"&gt;.trim_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_edge_whitespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.nfc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="nf"&gt;.rsplit_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'@'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"not an email address"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"not an email address"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Policy choice: lowercase the local part too (see RFC 5321, section 2.4)&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}@{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="nf"&gt;.to_ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="nf"&gt;.to_ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Pseudonymous join key: the same email always gives the same key.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;customer_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"key must be at least 32 bytes"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HmacSha256&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"invalid key"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="nf"&gt;.as_bytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mac&lt;/span&gt;
        &lt;span class="nf"&gt;.finalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.into_bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{b:02x}"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{key_version}:{digest}"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 1. A pseudonymous customer key from a normalised email address. Both implementations must return the same bytes for the same input.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most of the listing is normalisation, and that's where the hard decisions are. The key only works as a join if every implementation produces the same bytes for the same address. That can mean two languages even inside one service, for example when the service is rewritten, or when its batch endpoint runs in a different language from the online one, and the obvious library calls don't always agree. Python's &lt;code&gt;str.strip()&lt;/code&gt; with no arguments, for example, removes a few control characters that Rust's &lt;code&gt;trim()&lt;/code&gt; keeps. So the listing trims a fixed set of characters, applies Unicode NFC normalisation so that "é" typed as one character or as "e" plus an accent gives the same key, and lowercases only ASCII letters. Both implementations are tested against the same set of input and output vectors, with the library versions they were generated on pinned.&lt;/p&gt;

&lt;p&gt;Lowercasing the local part, the bit before the @, is a policy decision. RFC 5321 says the local part must be treated as case-sensitive, while discouraging anyone from relying on that (Klensin, 2008). Lowercasing it accepts a small chance of merging two people's addresses in exchange for fewer split records. Leaving non-ASCII letters alone means "ÉLODIE" and "élodie" produce different keys, which is the cost of keeping the result identical in every language.&lt;/p&gt;

&lt;p&gt;The version prefix on each key makes rotation possible. A new secret key gets a new version. Re-keying means recomputing from the email address, so it can only run where addresses are still held, such as the back office or the loyalty platform. Systems that hold only keys, such as the data platform, are re-keyed from an old-to-new key table produced where the addresses are, and that table should be held as tightly as the secret and deleted once re-keying finishes, because anyone holding it and the old secret can link new keys to addresses. Keeping an address-to-key mapping in the key service would make re-keying easier, but it turns the service into a central store of email addresses that needs the same protection as the secret. Because of that cost, some teams rotate only when a key may have been exposed. During a rotation both versions stay live, and the service issues both for new records until every stored record has been re-keyed.&lt;/p&gt;

&lt;p&gt;A customer key isn't anonymous. Under the GDPR, pseudonymisation means data can't be attributed to a person without additional information that is kept separately and protected (European Parliament and Council, 2016, Art. 4(5)), and pseudonymised data that could be attributed using that information is still personal data (European Parliament and Council, 2016, Recital 26). California's CCPA defines pseudonymization along similar lines (California Legislature, n.d., §1798.140(aa)). The key cuts the number of systems that need raw addresses to join records, and makes erasure and access requests easier to trace, but the same privacy obligations apply to every system that stores it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data read where it lives
&lt;/h3&gt;

&lt;p&gt;In Figure 1, the loyalty vendor's history export lands in an S3 bucket in the retailer's own AWS account, and the loyalty integration writes a member-to-customer-key map beside it. Keeping the map beside the export lets BigQuery Omni, covered in the next paragraph, join loyalty history to customer keys inside AWS, so only keyed, filtered results cross to Google Cloud. Vendor exports are often CSV or Parquet files. If the vendor writes Apache Iceberg tables, or the retailer converts the export, engines such as Spark, Trino, and Flink can work with the same tables at the same time (Apache Iceberg, n.d.).&lt;/p&gt;

&lt;p&gt;BigQuery Omni is one way for the data platform on Google Cloud to use that data without copying it first. It runs the BigQuery query engine in AWS and Azure regions over data in S3 or Azure Blob Storage, through BigLake tables, so processing happens where the data sits (Google Cloud, n.d.b). Results can come back to Google Cloud or be written straight to S3. The options differ in practice: cross-cloud joins suit one-off queries over data that isn't large, with a limit of 60 GB per transfer, while materialised view replicas suit repeated queries such as a dashboard. Data moved out of AWS this way still incurs AWS egress charges (Google Cloud, n.d.b), so a filtered read, such as one store-day of loyalty redemptions for one brand, should cost less than moving the whole table. For Iceberg tables in S3 that are managed by a catalog such as AWS Glue, Google now points to cross-cloud data access through its Lakehouse runtime catalog instead (Google Cloud, n.d.c), so the right route depends on how the export is catalogued.&lt;/p&gt;

&lt;p&gt;The SQL Server systems are a different case. Their data changes constantly and the data platform needs it alongside sales from every other source, so the sync job reads SQL Server's change data capture and merges changed rows into BigQuery. A managed service can do the same job. Google's Datastream reads change tables or transaction logs from self-managed SQL Server, including SQL Server on a cloud VM, and change tables from Azure SQL Database at service objective S3 (a Standard-tier size, unrelated to Amazon S3) or above (Google Cloud, n.d.f), and can stream them to BigQuery (Google Cloud, n.d.h). For SQL Server it doesn't support Windows Active Directory authentication (Google Cloud, n.d.f), and its setup creates a SQL Server login with a password for the connection (Google Cloud, n.d.h), so there's a stored credential to manage, which is worth weighing against a sync job that uses its managed identity. Replicating the email column as it is in the source means email addresses arrive in BigQuery unkeyed and need keying and removal there, which brings raw addresses back into the data platform. Excluding the email column avoids that, but then those rows have to be keyed at source. Either way it's a deliberate copy, with a known source, owner, and cadence.&lt;/p&gt;

&lt;p&gt;A copy is worth making when it has a specific reason. A dashboard that refreshes every few minutes, a dataset that has to sit in a particular country, a replica kept for recovery, or operational data that has to be joined with sales and other sources, like the SQL Server feed, can each justify one. Each of those copies goes in the same record of copies, so that it can be found, refreshed, and eventually removed.&lt;/p&gt;

&lt;p&gt;Events need the same treatment at a smaller scale. Aggregator webhooks arrive in each aggregator's own format. CloudEvents, a CNCF graduated specification for describing event data in a common way, gives every order event the same envelope, and its adopters include Amazon EventBridge, Azure Event Grid, and Google Cloud Eventarc (CloudEvents, n.d.). Wrapping each aggregator's order in that envelope at the integration service means downstream consumers in any cloud read one format, and once the integration service has a customer key for an order from the key service, the event can carry the key in place of the email address.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the estate looks like with this in place
&lt;/h2&gt;

&lt;p&gt;A few things change that teams outside the platform group will notice. Head-office staff use their Entra ID identity in Azure and Google Cloud. Long-lived credentials between clouds are the exception, kept only where a service such as Datastream, or a vendor integration with the S3 bucket that only supports access keys, requires one, and then recorded and rotated; elsewhere each cloud holds trust relationships with conditions narrowed to the workloads that need them. Every system that stores customers stores the same customer key, issued by one service, and an opt-out or an erasure request can be traced across all of them by that key. Each dataset of record has one owner and one location, and every copy has a recorded reason. Aggregator events reach every consumer in one envelope.&lt;/p&gt;

&lt;p&gt;The estate also becomes easier to change. Moving the back office off SQL Server, replacing the loyalty platform, or bringing an acquired brand's data in becomes mostly a matter of re-pointing trust relationships and readers, while the customer key and event format can stay the same. Deciding which workloads stay where they are, and which are worth consolidating, is placement work Sakura Sky's &lt;a href="https://www.sakurasky.com/cloud/" rel="noopener noreferrer"&gt;Cloud practice&lt;/a&gt; does with retail and QSR teams.&lt;/p&gt;

&lt;p&gt;Trust conditions and key versions drift as vendors and teams change, and reviewing them is ongoing work Sakura Sky's &lt;a href="https://www.sakurasky.com/managed-services/" rel="noopener noreferrer"&gt;Managed Services&lt;/a&gt; team takes on.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Apache Iceberg, n.d. &lt;em&gt;Apache Iceberg™: The open table format for analytic datasets.&lt;/em&gt; Apache Software Foundation. Available at: &lt;a href="https://iceberg.apache.org/" rel="noopener noreferrer"&gt;https://iceberg.apache.org/&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;AWS, n.d.a. &lt;em&gt;AssumeRoleWithWebIdentity.&lt;/em&gt; AWS Security Token Service API Reference. Available at: &lt;a href="https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;AWS, n.d.b. &lt;em&gt;Getting started with outbound identity federation.&lt;/em&gt; AWS Identity and Access Management User Guide. Available at: &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound_getting_started.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound_getting_started.html&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;AWS, n.d.c. &lt;em&gt;IAM and AWS STS condition context keys.&lt;/em&gt; AWS Identity and Access Management User Guide. Available at: &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;AWS, n.d.d. &lt;em&gt;Revoke IAM role temporary security credentials.&lt;/em&gt; AWS Identity and Access Management User Guide. Available at: &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;California Legislature, n.d. &lt;em&gt;California Civil Code §1798.140 (California Consumer Privacy Act).&lt;/em&gt; California Legislative Information. Available at: &lt;a href="https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.140" rel="noopener noreferrer"&gt;https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.140&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;CloudEvents, n.d. &lt;em&gt;CloudEvents: A specification for describing event data in a common way.&lt;/em&gt; Cloud Native Computing Foundation. Available at: &lt;a href="https://cloudevents.io/" rel="noopener noreferrer"&gt;https://cloudevents.io/&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;European Parliament and Council, 2016. &lt;em&gt;Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation).&lt;/em&gt; Official Journal of the European Union, L 119, 4 May, pp. 1-88. Available at: &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2016/679/oj&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.a. &lt;em&gt;Configure Workload Identity Federation with AWS or Azure VMs.&lt;/em&gt; Identity and Access Management documentation. Available at: &lt;a href="https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.b. &lt;em&gt;Introduction to BigQuery Omni.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/omni-introduction" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/omni-introduction&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.c. &lt;em&gt;Create Apache Iceberg external tables.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/iceberg-external-tables" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/iceberg-external-tables&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.d. &lt;em&gt;Workforce Identity Federation.&lt;/em&gt; Identity and Access Management documentation. Available at: &lt;a href="https://docs.cloud.google.com/iam/docs/workforce-identity-federation" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/iam/docs/workforce-identity-federation&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.e. &lt;em&gt;MAC signatures.&lt;/em&gt; Cloud Key Management Service documentation. Available at: &lt;a href="https://docs.cloud.google.com/kms/docs/mac-signatures" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/kms/docs/mac-signatures&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.f. &lt;em&gt;Stream data from SQL Server databases.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/sources-sqlserver" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/sources-sqlserver&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.g. &lt;em&gt;Identity federation: products and limitations.&lt;/em&gt; Identity and Access Management documentation. Available at: &lt;a href="https://docs.cloud.google.com/iam/docs/federated-identity-supported-services" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/iam/docs/federated-identity-supported-services&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.h. &lt;em&gt;Configure a self-managed SQL Server database for CDC.&lt;/em&gt; Datastream documentation. Available at: &lt;a href="https://docs.cloud.google.com/datastream/docs/configure-self-managed-sqlserver" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/datastream/docs/configure-self-managed-sqlserver&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Klensin, J., 2008. &lt;em&gt;RFC 5321: Simple Mail Transfer Protocol.&lt;/em&gt; Internet Engineering Task Force. Available at: &lt;a href="https://datatracker.ietf.org/doc/html/rfc5321" rel="noopener noreferrer"&gt;https://datatracker.ietf.org/doc/html/rfc5321&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

&lt;p&gt;Microsoft, 2025. &lt;em&gt;Workload identity federation concepts.&lt;/em&gt; Microsoft Entra Workload ID documentation. Available at: &lt;a href="https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation&lt;/a&gt; [Accessed 9 October 2026].&lt;/p&gt;

</description>
      <category>retail</category>
      <category>cloud</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Thousands of Sites, One Pipeline</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Thu, 08 Oct 2026 09:00:31 +0000</pubDate>
      <link>https://dev.to/sakurasky/thousands-of-sites-one-pipeline-434a</link>
      <guid>https://dev.to/sakurasky/thousands-of-sites-one-pipeline-434a</guid>
      <description>&lt;p&gt;When your operations team and your analytics team bring different sales figures for the same day to the same meeting, the discussion tends to turn to whose figure is right. A multi-brand QSR operator with two thousand sites might run three POS systems, two loyalty platforms, four delivery integrations, and an inventory layer built in-house. In that kind of network, analytics dashboards often get checked against other sources before anyone acts on them, and operations keeps its own dashboards built from its own extracts.&lt;/p&gt;

&lt;p&gt;The usual response targets the dashboards: a new BI tool, a semantic layer, or a working group to agree definitions. Those can help, agreed definitions especially, but they act after the steps where data is collected, assigned to a day, and matched across sources. In many networks the disagreement is created at that earlier stage. This post works backwards from one disputed number to those causes, then forwards to the pipeline and the operating disciplines that reduce them. Our earlier post &lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-4/" rel="noopener noreferrer"&gt;Telemetry Is Becoming the Business&lt;/a&gt; made the cross-industry case that operational telemetry now has to be engineered as a data product, and a network's sales record is a clear case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The competing dashboards
&lt;/h2&gt;

&lt;p&gt;Take one store, the fictional store 0412, on a Saturday. It takes delivery through one aggregator, which injects orders into its POS. At about 11pm on Saturday its broadband failed. The cellular failover carried only card payments and order injection, so the back-office upload stopped until Sunday morning. The operations dashboard shows net sales of $18,420. That's the figure in the POS end-of-day report the store manager signs off. The report arrived on Sunday morning when the upload resumed. The analytics dashboard, built from transaction feeds loaded overnight, shows $19,255.&lt;/p&gt;

&lt;p&gt;Rather than argue over whose figure is right, the two teams can build a reconciliation bridge, which walks from one figure to the other, one cause at a time. The figures are illustrative, but in our experience the line items are the kind that turn up when this is done on a real network.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;th&gt;Running total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Operations dashboard (POS end-of-day report)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$18,420&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trading-day boundary: the POS counts sales up to its 4am cutoff as the previous day's; the warehouse uses calendar date&lt;/td&gt;
&lt;td&gt;−$1,160&lt;/td&gt;
&lt;td&gt;$17,260&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactions recorded before midnight while the link was down, which missed the overnight load&lt;/td&gt;
&lt;td&gt;−$640&lt;/td&gt;
&lt;td&gt;$16,620&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregator-feed copies of orders the POS also recorded (excluding tax and fees)&lt;/td&gt;
&lt;td&gt;+$2,175&lt;/td&gt;
&lt;td&gt;$18,795&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax and customer-paid delivery and service fees on those aggregator copies, counted as net sales&lt;/td&gt;
&lt;td&gt;+$460&lt;/td&gt;
&lt;td&gt;$19,255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics dashboard&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$19,255&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the warehouse, Saturday night's midnight-to-4am window drops out of Saturday and Friday night's comes in, which nets to the first line. Sales after midnight during the outage were also late; the bridge counts them under the trading-day line and leaves them out of the late-transactions line. The late-transactions line exists because the analytics model loads each day once and doesn't go back for late arrivals.&lt;/p&gt;

&lt;p&gt;Two lines are rules that differ between teams, one is an arrival gap, and one is a duplicate the analytics model doesn't look for. At this store the fee line disappears with the duplicates. The rule still matters for every aggregator order the POS never sees. Tax is not part of net sales, and customer-paid delivery and service fees on marketplace orders usually belong to the aggregator. Resolve all four, using this POS's definition of net, and store 0412 comes to $18,420, the POS figure, because every sale here went through the POS. Where injection fails during an outage and orders are taken on the aggregator's tablet instead, those orders exist only in the aggregator feed. Orders a POS never sees are added on top and reconciled at settlement.&lt;/p&gt;

&lt;p&gt;There's often a fifth item that never makes the bridge, because it has to be solved before any comparison can be made. The store is 0412 in the POS, carries a different code in the inventory system, and has a third identifier in the delivery platform's merchant portal. Before anyone can compare a figure, someone has to know which records belong to the same store.&lt;/p&gt;

&lt;h2&gt;
  
  
  How QSR data estates fragment
&lt;/h2&gt;

&lt;p&gt;In our experience, networks like this grow one system at a time. Each system arrives to solve a specific problem and brings its own clock, store identifiers, and definition of a sale. An acquisition brings a second POS. A brand launched in another market brings a third. Delivery marketplaces are often integrated one at a time, some injecting orders into the POS and some not, and the choice can differ by brand, by market, or by aggregator. The loyalty platform from &lt;a href="https://www.sakurasky.com/blog/qsr-retail-engineering-part-1/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; adds its own view of the same transactions. Unifying data that sat in separate point-of-sale, loyalty, marketing, supply chain, and franchisee reporting silos on a single data engineering platform was a core part of Sakura Sky's work with &lt;a href="https://www.sakurasky.com/case-studies/our-work-with-craveable-brands-featured-as-a-google-cloud-platform-case-study/" rel="noopener noreferrer"&gt;Craveable Brands&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Franchise networks add another layer. Some systems are chosen by franchisees, and the franchisor may receive only what the franchise agreement provides for, sometimes a daily sales report rather than transaction detail. The royalty base is usually a contractual definition of gross sales, so any published definition of net also has to map back to it.&lt;/p&gt;

&lt;p&gt;Each team then builds the extracts it needs for its own questions. Operations takes the POS end-of-day reports because they come from the same close the manager signs off and counts the tills against. Analytics builds from the transaction-level feeds because it needs baskets, dayparts, and channels. Finance usually books from the daily sales journal and closes against card settlement, aggregator payouts, and bank deposits. All three are reasonable choices, and all three produce numbers that differ for reasons like those in the bridge.&lt;/p&gt;

&lt;p&gt;When the numbers disagree, the fix usually lands in the dashboard layer, as a filter that excludes one aggregator's feed, a calculated field that subtracts tax, or a date shift for after-midnight trading. Each fix lives in one dashboard or one team's semantic model, so dashboards built elsewhere from the same tables don't have it, and the copies accumulate.&lt;/p&gt;

&lt;p&gt;The dashboard layer is also a poor place for some of these rules. It can't recover transactions that weren't loaded. Matching duplicate orders and assigning trading days can be done there, but every model has to repeat the work, and the copies drift apart. A shared semantic layer is a reasonable place to expose the definition of net, but matching orders and recovering late data have to happen upstream of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A medallion pipeline for a multi-site network
&lt;/h2&gt;

&lt;p&gt;What matters most in the architecture is where each decision is made, whichever products implement it. Much of Sakura Sky's lakehouse work uses the &lt;a href="https://www.sakurasky.com/blog/data-lakehouse-medallion-architecture/" rel="noopener noreferrer"&gt;medallion pattern&lt;/a&gt; for this. Bronze holds raw data as received, silver holds cleaned, deduplicated, and conformed records, and gold holds the aggregated, business-ready tables that dashboards read (Databricks, 2026; Google Cloud, n.d.d). Figure 1 shows the layers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-07-qsr-one-pipeline-architecture.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-07-qsr-one-pipeline-architecture.png" title="Figure 1. A medallion pipeline for an example multi-site network." alt="Figure 1. A medallion pipeline for an example multi-site network. Every source lands in bronze as received (with card numbers truncated), is parsed and conformed once in silver against a shared store master, and feeds one set of gold metrics that operations, analytics, and finance read." width="800" height="1158"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ingestion jobs (API pulls from POS and aggregator platforms, file drops, webhooks, and event streams) write each source's data into the bronze layer as received. Bronze is append-only apart from retention and erasure deletes. It also holds the POS end-of-day reports and payment settlement files, which silver later parses into control totals. The one exception to "as received" is cardholder data. Incoming data is checked for full card numbers or track data at ingestion. Card numbers are truncated and track data is removed before landing, and each hit is logged (without the data) and raised with the source system to fix. The ingestion step that does this, and any drop zone or queue the raw files pass through first, handles card data, so in our designs it's treated as inside PCI DSS scope and kept small and locked down.&lt;/p&gt;

&lt;p&gt;Bronze also needs retention and erasure rules for personal data, such as loyalty identifiers and the customer names and addresses in delivery orders. Erasure has to reach every layer. In silver and gold it only fully takes effect once BigQuery's time travel window has passed (Google Cloud, n.d.b) or old Iceberg snapshots have been expired, and in bronze once any soft-deleted or versioned objects have aged out.&lt;/p&gt;

&lt;p&gt;That raw history is what lets the pipeline reprocess a store-day when a rule changes or a late batch arrives (Databricks, 2026; Google Cloud, n.d.d), without asking the POS vendor to resend anything. Parsing happens after landing, with one parser per source system and version, so a change in one vendor's export format is contained in one place.&lt;/p&gt;

&lt;p&gt;Silver is where most of the bridge items get resolved, once. Databricks lists deduplication and the resolution of out-of-order and late-arriving data among the jobs of this layer (Databricks, 2026). That's where the duplicate line belongs, though matching one order across two systems is closer to entity resolution than to removing repeated rows. The late-transactions line is closed by reprocessing the day from bronze, with gold's store-day status showing it as provisional until then.&lt;/p&gt;

&lt;p&gt;The trading day is a conforming rule applied in silver, using the store master (silver reference data). The store master maps each source system's code to one store key and records the store's time zone, trading-day cutoff, delivery integrations, and status. It keeps each attribute with the dates it applied, so reprocessing an old day uses the cutoff and codes that applied then.&lt;/p&gt;

&lt;p&gt;Each order becomes one sales event carrying the store key, the event time, and a business date (the trading day it belongs to). Its line items are kept alongside, and refunds and post-tender voids are recorded as their own events. Money is kept as components (gross, discounts, brand-funded and aggregator-funded promotions, tax and who remits it, customer-paid delivery and service fees, aggregator commission, and tips), so net sales becomes a published definition built from those components. Networks that trade in more than one currency also need a stated conversion policy for any network-wide figure.&lt;/p&gt;

&lt;p&gt;Orders that arrive from more than one source carry a cross-source link, typically the aggregator's order ID where the POS integration records it. Where it doesn't, a fallback match on store, time, and amount pairs orders one-to-one within a tolerance, since the two copies rarely carry identical amounts (modifiers, promotions, and tax are often recorded differently). Ambiguous candidates are left unmatched. Until it's matched, the aggregator copy of an order from an injecting integration is held out of net sales, so a missed match understates delivery rather than double-counting it. Once the store-day reconciles to its end-of-day report, the POS side is treated as complete. Any aggregator orders still unmatched are then checked against the day's POS orders from any channel, because staff often re-key tablet orders by hand when injection fails. Those still unmatched after a holding period are released into net as aggregator-only sales and flagged. That changes the store-day's net but not its reconciled status, which covers POS-sourced orders only, and they're tracked against settlement rather than counted as corrections. A rising rate of unmatched orders is treated as an integration fault to fix at source, usually by getting the POS integration to record the aggregator's order ID.&lt;/p&gt;

&lt;p&gt;The merged order keeps the POS business date, so the two copies can't land on different trading days near the cutoff. It also keeps both sets of money components, the POS's for reconciling to the end-of-day report and the aggregator's for settlement. Net sales uses the POS's components.&lt;/p&gt;

&lt;p&gt;Where the POS records its own business date, the pipeline usually adopts it, because that's the day the end-of-day report covers. Listing 1 is for the sources that don't, such as aggregator feeds, and assigns each event to the store's trading day from its time and the store's configured cutoff. Where managers run the POS close by hand, the cutoff approximates it. Aggregator feeds can carry several timestamps per order, so the rule uses one agreed timestamp, usually when the order was accepted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;zoneinfo&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ZoneInfo&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;     &lt;span class="c1"&gt;# one key across POS, delivery, loyalty, and inventory
&lt;/span&gt;    &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;            &lt;span class="c1"&gt;# IANA zone name, e.g. "America/Chicago"
&lt;/span&gt;    &lt;span class="n"&gt;day_cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;   &lt;span class="c1"&gt;# trading day rolls over here, e.g. 04:00; keep it
&lt;/span&gt;                       &lt;span class="c1"&gt;# clear of the hour that repeats when clocks go back
&lt;/span&gt;    &lt;span class="c1"&gt;# zone and cutoff are the store's attributes as of the event's date
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__post_init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nc"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# fail fast on an unknown zone name
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day_cutoff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day_cutoff must be naive local time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;business_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The trading day an event belongs to, in the store&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s own terms.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcoffset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_time must be timezone-aware&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day_cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rust&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;chrono&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NaiveDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NaiveTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;chrono_tz&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Tz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;store_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// one key across POS, delivery, loyalty, and inventory&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Tz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// IANA zone, e.g. chrono_tz::America::Chicago&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;day_cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NaiveTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// trading day rolls over here, e.g. 04:00; keep it&lt;/span&gt;
                               &lt;span class="c1"&gt;// clear of the hour that repeats when clocks go back&lt;/span&gt;
    &lt;span class="c1"&gt;// zone and cutoff are the store's attributes as of the event's date&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// The trading day an event belongs to, in the store's own terms.&lt;/span&gt;
&lt;span class="cd"&gt;/// Taking `DateTime&amp;lt;Utc&amp;gt;` means a time without a zone can't be passed in.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;business_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_utc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NaiveDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event_utc&lt;/span&gt;&lt;span class="nf"&gt;.with_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="py"&gt;.tz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="nf"&gt;.time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="py"&gt;.day_cutoff&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="nf"&gt;.date_naive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nn"&gt;Days&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="nf"&gt;.date_naive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 1. Assigning an event to the store's trading day, using the store's IANA time zone and cutoff.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The store carries an IANA time zone name instead of a fixed UTC offset, because offsets and daylight-saving rules change by government decision. The IANA Time Zone Database is updated to track those changes (IANA, n.d.). Its 2026 releases record British Columbia, Alberta, the Northwest Territories, and Manitoba moving to permanent UTC offsets (IANA, 2026), so a service on older rules would put most stores in those regions an hour out each winter, starting in November 2026.&lt;/p&gt;

&lt;p&gt;The name only helps if the database behind it is current. Python's zoneinfo reads the operating system's copy or the tzdata package (Python Software Foundation, 2026), so updating those is the usual route. ZoneInfo also returns cached zone objects, so a long-running process can keep serving the old rules until it restarts or clears the cache. The chrono-tz crate builds the database into the binary at compile time (chrono-tz contributors, 2025), so a change normally arrives with a crate release that includes it, followed by a rebuild. Its latest release at the time of writing, 0.10.4 from July 2025, predates all four changes. A Rust service for stores in those regions needs either a chrono-tz release with current rules, once one ships, or a time zone source that reads the system's database at runtime. Listing 1 uses chrono-tz to keep the example short. Rules applied in SQL or in Spark typically use that engine's own copy of the database, which needs the same attention.&lt;/p&gt;

&lt;p&gt;Daylight saving also makes some trading days shorter or longer than usual, which is worth flagging on hourly and same-day-last-year comparisons. Some POS exports record only local wall-clock time with no offset. The parser then converts using the store's zone, and both the skipped hour when clocks go forward and the repeated hour when they go back need an explicit rule. Python's zoneinfo, for example, treats a repeated time as its first occurrence (fold=0) unless told otherwise (Python Software Foundation, 2026).&lt;/p&gt;

&lt;p&gt;Gold metrics are then computed once, upstream of every dashboard, with their definitions published alongside them. Operations, analytics, and finance can still build different views, at different grains and for different questions, but they read the same net sales for store 0412 on Saturday.&lt;/p&gt;

&lt;p&gt;Google Cloud's medallion explainer puts bronze in Cloud Storage and silver and gold in BigQuery, with the transformations written in SQL (for example with Dataform) or in Apache Spark (Google Cloud, n.d.d).&lt;/p&gt;

&lt;p&gt;Networks that want to stay on open formats can keep silver and gold as Apache Iceberg tables (Apache Iceberg, n.d.a). BigQuery's Iceberg managed tables (formerly BigLake tables for Apache Iceberg in BigQuery) store the data in buckets the customer owns. Writes go through BigQuery, including from Spark and Dataflow via its Storage Write API. Open-source engines such as Spark can read the tables, though Google warns that modifying the data files outside BigQuery can cause query failure or data loss (Google Cloud, n.d.a). Late store-days are reprocessed through BigQuery's DML (Google Cloud, n.d.a). Where Spark manages its own Iceberg tables through its own catalog, MERGE INTO does the same job (Apache Iceberg, n.d.d).&lt;/p&gt;

&lt;p&gt;Neither platform's time travel is a long-term record. BigQuery's window is configurable from two to seven days, with seven by default (Google Cloud, n.d.b). Iceberg snapshots last until they're expired, and expiring them regularly is recommended, after which they're no longer available for time travel (Apache Iceberg, n.d.c). A month-end state can be kept longer with a BigQuery table snapshot of a standard table, taken at month-end (Google Cloud, n.d.c), or with an Iceberg tag where Spark or another engine manages the Iceberg catalog (Apache Iceberg, n.d.b). BigQuery's Iceberg managed tables don't support table snapshots (Google Cloud, n.d.a), so on those the month-end state is written out explicitly.&lt;/p&gt;

&lt;p&gt;Finance and franchisees need to know what a store-day showed when a month-end report or royalty statement was produced. A pinned copy doesn't record why a figure changed, so revision history is kept explicitly, as a gold table of store-day versions with the reason for each change.&lt;/p&gt;

&lt;p&gt;The same layering works on Databricks (Databricks, 2026) or a self-managed Iceberg catalog, and the platform choice is a question for an initial &lt;a href="https://www.sakurasky.com/cloud/" rel="noopener noreferrer"&gt;cloud assessment&lt;/a&gt;. Part 3 of this series will look at networks whose data already spans more than one cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operating disciplines around the pipeline
&lt;/h2&gt;

&lt;p&gt;An architecture like Figure 1 can still produce numbers nobody trusts if the pipeline treats each day as finished as soon as a load has run. The Dataflow Model paper treats this as a starting assumption: for data that arrives continuously and out of order, a system can't know whether it has seen everything, only that new data will arrive and earlier data may be retracted (Akidau et al., 2015). A two-thousand-site network behaves like that kind of source. On a typical night in a network this size, some stores are likely to be offline and some POS terminals likely to forward late.&lt;/p&gt;

&lt;p&gt;The discipline that follows is to make each store-day's state visible. Listing 2 is a simple version. A store-day is open until its trading day ends. It's provisional until the pipeline's net for POS-sourced orders agrees with the store's end-of-day report, and reconciled once it does. That report covers only what went through the POS. The comparison uses each POS's own definition of net, rebuilt from the stored components, so a definitional difference doesn't look like missing data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;OPEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                &lt;span class="c1"&gt;# trading day not yet ended
&lt;/span&gt;    &lt;span class="n"&gt;PROVISIONAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provisional&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# ended, not yet reconciled
&lt;/span&gt;    &lt;span class="n"&gt;RECONCILED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reconciled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# agrees with the store's end-of-day report
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;store_day_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trading_day_ended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pipeline_pos_net_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# pipeline net for POS-sourced orders, in minor units
&lt;/span&gt;    &lt;span class="n"&gt;pos_eod_net_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# None until the end-of-day report lands
&lt;/span&gt;    &lt;span class="n"&gt;tolerance_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# set per POS; line rounding and tax-inclusive
&lt;/span&gt;                                     &lt;span class="c1"&gt;# pricing can differ
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tolerance_minor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tolerance_minor must not be negative&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;trading_day_ended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPEN&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pos_eod_net_minor&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROVISIONAL&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pipeline_pos_net_minor&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pos_eod_net_minor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tolerance_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROVISIONAL&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RECONCILED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rust&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;DayStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// trading day not yet ended&lt;/span&gt;
    &lt;span class="n"&gt;Provisional&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ended, not yet reconciled&lt;/span&gt;
    &lt;span class="n"&gt;Reconciled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// agrees with the store's end-of-day report&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;store_day_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;trading_day_ended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pipeline_pos_net_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// pipeline net for POS-sourced orders, in minor units&lt;/span&gt;
    &lt;span class="n"&gt;pos_eod_net_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// None until the end-of-day report lands&lt;/span&gt;
    &lt;span class="n"&gt;tolerance_minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// set per POS; line rounding and tax-inclusive&lt;/span&gt;
                                    &lt;span class="c1"&gt;// pricing can differ&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DayStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;trading_day_ended&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nn"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;pos_eod_net_minor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pipeline_pos_net_minor&lt;/span&gt;&lt;span class="nf"&gt;.abs_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;tolerance_minor&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Reconciled&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;DayStatus&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Provisional&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Listing 2. Store-day status: open, provisional, or reconciled against the store's own end-of-day report.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Orders that never pass through the POS, such as those from non-injecting aggregators, aren't covered by this check; they're reconciled against the aggregator's own reports or settlement, usually later. A production version also records why a day is provisional (report missing or totals that don't match) and how long it has been in that state, and compares transaction counts as well as net, since totals alone can hide offsetting errors.&lt;/p&gt;

&lt;p&gt;Franchised stores that send only a daily sales report have nothing at transaction level to reconcile it against, so those store-days carry their own status. They're checked against card settlement or aggregator data where the franchise agreement provides it, and against royalty submissions.&lt;/p&gt;

&lt;p&gt;The status is recalculated whenever a store-day's events change, so a reconciled day returns to provisional if a late batch moves its POS-sourced total. In the estates we work with, refunds are usually booked to the day they're processed, matching the POS, so a reconciled day isn't reopened by them.&lt;/p&gt;

&lt;p&gt;A change to a provisional day is a revision, and a change to a day that had already reconciled is a correction. The revision history covers both. Neither is a restatement in the accounting sense.&lt;/p&gt;

&lt;p&gt;Once finance has closed a period, a small correction is usually carried into the open period, and a correction to a day already covered by a royalty statement becomes an adjustment on the next one. Gold keeps the corrected store-day for operational analysis, and the revision history keeps the version that finance and the franchisee were given.&lt;/p&gt;

&lt;p&gt;Figure 2 follows store 0412 through its lost link. Saturday shows as provisional, with its end-of-day report counted as missing in the coverage figure, until the buffered transactions and the report arrive on Sunday morning and the day reconciles. The revised figure then reaches both teams' dashboards from the same table on their next refresh, with a note that the day was revised.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-07-qsr-late-store-revision.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.sakurasky.com%2Fimages%2Fblog%2Fmermaid-diagram-2026-10-07-qsr-late-store-revision.png" title="Figure 2. Revising a store-day after a lost link." alt="Figure 2. A store that traded with its back-office link down. The store-day stays provisional until the buffered transactions and end-of-day report arrive; then both dashboards show the same revised figure." width="800" height="1232"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The status depends on a few other practices. Coverage is reported every day as store-days with an end-of-day report received against store-days expected to trade, which needs the store master to know about openings, closures, refits, and changes of trading hours. The store master itself has an owner, because a franchise transfer or a reopened site under a new code breaks joins without warning, and the first sign is often a dashboard that looks wrong weeks later.&lt;/p&gt;

&lt;p&gt;Each parser has tests against sample files from its source system. POS and aggregator releases are also tracked; in our experience, a POS release rolled out store by store often shows up as a gradual drift in one brand's numbers. Card processor settlement and aggregator payouts give a second reconciliation point independent of the stores, usually run later and at finance's grain, while cash goes through deposit reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes for operations and analytics
&lt;/h2&gt;

&lt;p&gt;For operations, the most visible change is that the figure they already reconcile to becomes the control total the whole pipeline is held to. Their dashboards move onto the gold layer, and the end-of-day report keeps its standing because it's the store's own close. Analytics changes too, giving up calendar-date grouping and its own tax and fee handling for the shared conforming rules in silver and the published definition of net in gold.&lt;/p&gt;

&lt;p&gt;Operations usually hears first about changes to trading hours, cutoffs, temporary closures, and refits. The pipeline now reads them from where operations records them, so a store that closes early for a refit doesn't show as missing. For franchised sites, coverage alerts go to the franchisee through the field team, under the reporting terms of the franchise agreement.&lt;/p&gt;

&lt;p&gt;The daily conversation changes shape too. A morning summary might read: 1,826 of 1,839 expected transaction-feed store-days reconciled to their POS end-of-day reports, and 13 provisional. Of 150 daily-report franchise stores, 148 have reported. Two previously reconciled days were corrected since yesterday and are reconciled again, and aggregator-only sales are still to reconcile at settlement.&lt;/p&gt;

&lt;p&gt;Disagreements still happen, but they move to definitions that can be decided once: whether staff meals and manager comps count as sales, whether operational figures follow finance in keeping gift card loads out until redemption, how loyalty redemptions and aggregator-funded promotions reduce net, which price counts for marketplace orders, and whether a delivery fee on the brand's own app counts as a sale. The decision is then applied in the pipeline, where every dashboard picks it up.&lt;/p&gt;

&lt;p&gt;Sakura Sky's &lt;a href="https://www.sakurasky.com/data/" rel="noopener noreferrer"&gt;Data &amp;amp; AI practice&lt;/a&gt; builds and runs pipelines like this for restaurant and retail networks, from POS and aggregator ingestion through to the store-day status that operations, analytics, and finance read.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Akidau, T., Bradshaw, R., Chambers, C., Chernyak, S., Fernández-Moctezuma, R.J., Lax, R., McVeety, S., Mills, D., Perry, F., Schmidt, E. and Whittle, S., 2015. The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing. &lt;em&gt;Proceedings of the VLDB Endowment&lt;/em&gt;, 8(12), pp. 1792-1803. Available at: &lt;a href="https://www.vldb.org/pvldb/vol8/p1792-Akidau.pdf" rel="noopener noreferrer"&gt;https://www.vldb.org/pvldb/vol8/p1792-Akidau.pdf&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Apache Iceberg, n.d.a. &lt;em&gt;Apache Iceberg™: The open table format for analytic datasets.&lt;/em&gt; Apache Software Foundation. Available at: &lt;a href="https://iceberg.apache.org/" rel="noopener noreferrer"&gt;https://iceberg.apache.org/&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Apache Iceberg, n.d.b. &lt;em&gt;Branching and Tagging.&lt;/em&gt; Apache Iceberg documentation. Available at: &lt;a href="https://iceberg.apache.org/docs/latest/branching/" rel="noopener noreferrer"&gt;https://iceberg.apache.org/docs/latest/branching/&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Apache Iceberg, n.d.c. &lt;em&gt;Maintenance.&lt;/em&gt; Apache Iceberg documentation. Available at: &lt;a href="https://iceberg.apache.org/docs/latest/maintenance/" rel="noopener noreferrer"&gt;https://iceberg.apache.org/docs/latest/maintenance/&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Apache Iceberg, n.d.d. &lt;em&gt;Spark Writes.&lt;/em&gt; Apache Iceberg documentation. Available at: &lt;a href="https://iceberg.apache.org/docs/latest/spark-writes/" rel="noopener noreferrer"&gt;https://iceberg.apache.org/docs/latest/spark-writes/&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;chrono-tz contributors, 2025. &lt;em&gt;chrono_tz (version 0.10.4).&lt;/em&gt; docs.rs. Available at: &lt;a href="https://docs.rs/chrono-tz/latest/chrono_tz/" rel="noopener noreferrer"&gt;https://docs.rs/chrono-tz/latest/chrono_tz/&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Databricks, 2026. &lt;em&gt;What is the medallion lakehouse architecture?&lt;/em&gt; Databricks documentation. Available at: &lt;a href="https://docs.databricks.com/aws/en/lakehouse/medallion" rel="noopener noreferrer"&gt;https://docs.databricks.com/aws/en/lakehouse/medallion&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.a. &lt;em&gt;Apache Iceberg managed tables.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/biglake-iceberg-tables-in-bigquery" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/biglake-iceberg-tables-in-bigquery&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.b. &lt;em&gt;Data retention with time travel and fail-safe.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/time-travel" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/time-travel&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.c. &lt;em&gt;Introduction to table snapshots.&lt;/em&gt; BigQuery documentation. Available at: &lt;a href="https://docs.cloud.google.com/bigquery/docs/table-snapshots-intro" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/bigquery/docs/table-snapshots-intro&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Google Cloud, n.d.d. &lt;em&gt;What is medallion architecture?&lt;/em&gt; Google Cloud. Available at: &lt;a href="https://cloud.google.com/discover/what-is-medallion-architecture" rel="noopener noreferrer"&gt;https://cloud.google.com/discover/what-is-medallion-architecture&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;IANA, 2026. &lt;em&gt;Time Zone Database Releases.&lt;/em&gt; Internet Assigned Numbers Authority. Available at: &lt;a href="https://www.iana.org/time-zones/releases" rel="noopener noreferrer"&gt;https://www.iana.org/time-zones/releases&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;IANA, n.d. &lt;em&gt;Time Zones.&lt;/em&gt; Internet Assigned Numbers Authority. Available at: &lt;a href="https://www.iana.org/time-zones" rel="noopener noreferrer"&gt;https://www.iana.org/time-zones&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

&lt;p&gt;Python Software Foundation, 2026. &lt;em&gt;zoneinfo: IANA time zone support.&lt;/em&gt; Python 3 documentation. Available at: &lt;a href="https://docs.python.org/3/library/zoneinfo.html" rel="noopener noreferrer"&gt;https://docs.python.org/3/library/zoneinfo.html&lt;/a&gt; [Accessed 8 October 2026].&lt;/p&gt;

</description>
      <category>retail</category>
      <category>data</category>
      <category>analytics</category>
      <category>observability</category>
    </item>
    <item>
      <title>The Loyalty Backend Is a Data Engineering Problem</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Wed, 07 Oct 2026 06:52:52 +0000</pubDate>
      <link>https://dev.to/sakurasky/the-loyalty-backend-is-a-data-engineering-problem-27p</link>
      <guid>https://dev.to/sakurasky/the-loyalty-backend-is-a-data-engineering-problem-27p</guid>
      <description>&lt;p&gt;Customers judge a loyalty programme by its balance. When the points from today's burger don't appear, show up late, or a reward is declined at the counter, the app and the offers lose credibility, however well the launch went.&lt;/p&gt;

&lt;p&gt;Accounting standards treat the balance seriously too. Under IFRS 15, points that give a customer a material right are a separate performance obligation. The customer has in effect paid in advance for future goods, so part of the transaction price is allocated to the points and held as a contract liability until they are redeemed or expire (European Commission, 2016, IFRS 15 para. B40). That allocation already reflects how likely the points are to be used (para. B42). Points that are never redeemed are often referred to as breakage. A business that expects breakage recognises it in line with the pattern of redemptions, and one that doesn't recognises it when redemption becomes remote (paras. B45 to B46). US GAAP's Topic 606 was issued alongside IFRS 15 with the aim of reaching the same conclusions (FASB, 2014). Either way, finance depends on a reliable redemption history, and that history comes from the loyalty backend.&lt;/p&gt;

&lt;p&gt;That puts a financial system behind a marketing programme, and promotions are where the two pull apart, because a double-points weekend changes the rules and the volume at once. Accrual is one of the workloads &lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-2/" rel="noopener noreferrer"&gt;Peak Load Is the Steady State&lt;/a&gt; treats as able to queue under pressure, which is safe when the backend is designed for it. The stages below follow one point through that design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enrol
&lt;/h2&gt;

&lt;p&gt;The customer signs up in the app, at a kiosk, or by giving an email address at the counter. The data engineering question here is identity. The same customer may enrol twice, once in the app and once at a kiosk, or may already exist in the CRM from an earlier programme. If the backend creates a new member for each, points get split across records, and the customer sees a lower balance than they expect. Resolving identity at enrolment, by matching on verified contact details and linking payment tokens (never card numbers) or app installs to one member, is usually simpler than merging accounts after points have been earned on both.&lt;/p&gt;

&lt;p&gt;Enrolment is also where the basis for using the member's data is recorded. Loyalty programmes have rules of their own. California allows financial incentive programmes, which include many loyalty schemes, but a business can enter a consumer into one only with prior opt-in consent that describes the material terms and can be revoked at any time (Cal. Civ. Code §1798.125(b)(3)). Under GDPR, a member can object at any time to direct marketing, including related profiling, after which their data can no longer be used for that purpose (European Parliament and Council, 2016, Art. 21(2) to (3)). Those choices have to travel with the member record, because the offer engine depends on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Earn
&lt;/h2&gt;

&lt;p&gt;The customer buys something and scans their app, or the POS links the payment to their member ID. This creates an earn event, and it's where a loyalty backend first meets the realities of a restaurant or store network.&lt;/p&gt;

&lt;p&gt;Stores don't always have a reliable connection. Many POS systems keep trading when the link drops and forward their transactions when it comes back, so earn events can arrive late, in bursts, and out of order. Transactions get voided, refunded, or amended after the fact. Some brands run more than one POS across their network, each with its own event format. Members also buy through several channels: an app order paid in the app and collected at a store that's offline, a kiosk that identifies members by QR code, or a delivery-aggregator order that arrives through a separate integration. Receipt-claim features, where a customer enters a code later, add earn events that are late by design.&lt;/p&gt;

&lt;p&gt;A backend that treats each incoming message as a fresh instruction to add points will double-count retried messages and miss refunds. The usual fixes come from other event-driven systems. Every earn event carries a key the source can reproduce on retry, often a composite of store, register, business date, and transaction number, because POS transaction IDs can repeat across stores. Voids and refunds reverse the original even when the points have already been spent, so the backend needs a rule for negative balances. Late events are applied in transaction order, and order-dependent rules, such as a bonus on the fifth visit, are recalculated when a late event lands earlier in the sequence.&lt;/p&gt;

&lt;p&gt;Points have cash value, so the event stream also carries abuse: staff attaching unclaimed transactions to their own member ID, accounts opened in bulk to farm a sign-up bonus, and takeovers of accounts with large balances. These tend to show up as patterns of velocity, device, and store in data the backend already processes, so fraud rules can often run on the accrual stream before points become redeemable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accrue
&lt;/h2&gt;

&lt;p&gt;Accrual is where earn events become points, by applying the programme's rules: the base rate, the bonus, the double-points weekend, the offer the customer activated in the app. A successful promotion raises volume and rule complexity together, which is why accrual is often where a backend falls behind.&lt;/p&gt;

&lt;p&gt;What matters most to customers is what the POS has to wait for. Identifying the member and checking which offers apply usually has to happen while the basket is open, because a discount changes the price. Posting the earned points usually doesn't. If the POS waits for accrual before completing the sale, a slow rules engine becomes a slow counter. If it writes the transaction to a durable queue and moves on, the counter stays fast and accrual can catch up after a spike. The synchronous path is then limited to member lookup and offer validation, ideally with a cached fallback.&lt;/p&gt;

&lt;p&gt;Catching up is acceptable when customers can see what's happening. A balance that shows points as pending, with the transaction that earned them, is likely to hold trust better than a balance that shows nothing with no explanation. Treating accrual as a stream processing problem, with a queue that absorbs the burst, a rules engine that scales out, and a lag metric someone watches, means a promotion spike shows up as pending points, not missing ones. For restaurant and retail brands, our &lt;a href="https://www.sakurasky.com/data/" rel="noopener noreferrer"&gt;Data &amp;amp; AI practice&lt;/a&gt; often starts with the accrual pipeline and its lag metric, because that's where a promotion shows up first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redeem
&lt;/h2&gt;

&lt;p&gt;Redemption is usually the hardest stage, because it has to be right at the moment it happens. A customer at the counter wants a free coffee with their points. The POS asks the backend whether the balance covers it, and if so, the points have to be deducted before the coffee is handed over.&lt;/p&gt;

&lt;p&gt;Two things can go wrong. If the balance the POS sees is behind accrual, a customer who has the points may be told they don't. If two redemptions happen close together, at the counter and in the app, a backend without a reservation step can let both through against the same points. Redemption needs a consistent view of the balance and a reservation that holds the points until the sale completes or is cancelled, so it often gets its own path through the backend, separate from the bulk accrual pipeline.&lt;/p&gt;

&lt;p&gt;Stores that keep trading offline face the same question at redemption. Some brands block rewards until the link returns, and others allow limited offline redemptions and reconcile afterwards. That's a trade-off between fraud exposure and goodwill, and the backend has to record which path each redemption took.&lt;/p&gt;

&lt;p&gt;Offers add another layer. Rewards are often limited by store, time of day, product, or quantity, and each limit is a rule the POS and the backend have to agree on. Where they disagree, the customer sees a reward in the app that the counter won't honour, which can undo some of the goodwill the promotion created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expire
&lt;/h2&gt;

&lt;p&gt;Many programmes expire points after a set period or a stretch of inactivity. For finance, expiry releases the liability held against those points (European Commission, 2016, IFRS 15 para. B40). For the backend, it's a scheduled job that runs against the event history, sends any reminder the programme's terms promise, and leaves a record that reconciliation can match. Expiry rules tend to change over a programme's life, so the backend has to know which rule applied to which point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconcile
&lt;/h2&gt;

&lt;p&gt;Each trading day, the loyalty ledger has to agree with the systems around it: the POS sales records, the payments data, and the finance team's liability account. Points issued should match qualifying transactions. Points redeemed should match the rewards given away at the counter. The outstanding balance should support the liability on the balance sheet, and the redemption history feeds the breakage estimate the auditors will ask about.&lt;/p&gt;

&lt;p&gt;Franchising adds another layer. In a franchise network, the store that issues points and the store that redeems them may belong to different franchisees. One common approach is a set reimbursement rate per reward, often funded from the marketing fund, and calculating it needs a store-level record of every earn and redemption event.&lt;/p&gt;

&lt;p&gt;A backend built with reconciliation in mind keeps the event history as the source of truth and derives balances from it, so any balance can be explained transaction by transaction. A running balance that's updated in place can be hard to reconcile after a busy promotion, because the history behind each number has to be reconstructed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Across the network
&lt;/h2&gt;

&lt;p&gt;Each stage tends to get harder across a large network. Hundreds or thousands of locations mean several POS versions in the field at once, different connectivity, different trading hours, and in many networks a mix of company-owned and franchised stores with their own systems.&lt;/p&gt;

&lt;p&gt;Loyalty data tends to be most useful in the same platform as sales and operational data, so marketing, operations, and finance work from one record. Sakura's Google Cloud data platform for Craveable Brands, the franchisor behind Red Rooster, Oporto, and Chicken Treat, brought loyalty and CRM records together with point-of-sale, marketing, and franchisee data across what was then more than 570 restaurants (&lt;a href="https://www.sakurasky.com/case-studies/our-work-with-craveable-brands-featured-as-a-google-cloud-platform-case-study/" rel="noopener noreferrer"&gt;Craveable Brands case study&lt;/a&gt;). Part 2 of this series looks at running a network of sites on one pipeline.&lt;/p&gt;

&lt;p&gt;After go-live, the lag alert, the daily reconciliation, and the POS adapter that breaks after a vendor release all need an owner. Our &lt;a href="https://www.sakurasky.com/managed-services/" rel="noopener noreferrer"&gt;Managed Services&lt;/a&gt; team takes that on for restaurant and retail brands.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;California Legislature, n.d. &lt;em&gt;California Civil Code §1798.125 (California Consumer Privacy Act).&lt;/em&gt; California Legislative Information. Available at: &lt;a href="https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.125" rel="noopener noreferrer"&gt;https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=CIV&amp;amp;sectionNum=1798.125&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;European Commission, 2016. &lt;em&gt;Commission Regulation (EU) 2016/1905 of 22 September 2016 amending Regulation (EC) No 1126/2008 adopting certain international accounting standards in accordance with Regulation (EC) No 1606/2002 of the European Parliament and of the Council as regards International Financial Reporting Standard 15.&lt;/em&gt; Official Journal of the European Union, L 295, 29 October. Available at: &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/1905/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2016/1905/oj&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;European Parliament and Council, 2016. &lt;em&gt;Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation).&lt;/em&gt; Official Journal of the European Union, L 119, 4 May, pp. 1-88. Available at: &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2016/679/oj&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;FASB, 2014. &lt;em&gt;Accounting Standards Update No. 2014-09, Revenue from Contracts with Customers (Topic 606).&lt;/em&gt; Financial Accounting Standards Board, May 2014. Available at: &lt;a href="https://storage.fasb.org/ASU%202014-09_Section%20A.pdf" rel="noopener noreferrer"&gt;https://storage.fasb.org/ASU%202014-09_Section%20A.pdf&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

</description>
      <category>retail</category>
      <category>data</category>
      <category>architecture</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Patient Data Across Boundaries</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Tue, 06 Oct 2026 09:52:43 +0000</pubDate>
      <link>https://dev.to/sakurasky/patient-data-across-boundaries-1dig</link>
      <guid>https://dev.to/sakurasky/patient-data-across-boundaries-1dig</guid>
      <description>&lt;p&gt;A trial that enrols patients in the EU, the US, Singapore, and Brazil holds its data under four data protection regimes. They don't all apply to every record. Which rules govern a participant's data depends on where that participant enrolled, what they agreed to, and who holds the copy in question. The trial's documents capture most of this: the protocol, the consent forms, the data transfer agreements. The data itself usually carries the site, the country, and the consent date, but rarely the legal basis, the permitted purposes, or the optional consents a participant did or didn't give.&lt;/p&gt;

&lt;p&gt;Those missing values decide what the platform may do next. This post sets out some example fields a multi-jurisdiction trial platform can carry, what values each can take, and where the four regimes differ. There's a reference table at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who holds the obligation
&lt;/h2&gt;

&lt;p&gt;Start with who the rules bind, because it isn't the same in every country. HIPAA binds covered entities, such as the hospitals and clinics running US sites, and their business associates. A sponsor usually isn't either. HIPAA governs what the site may disclose to the sponsor, and the authorisation a US participant signs has to warn that information disclosed under it may no longer be protected by the Privacy Rule (HHS, n.d.a, §164.508(c)(2)(iii)). From there, HIPAA no longer applies, and what binds the sponsor is the informed consent it obtained under FDA rules, its contracts with sites, and any applicable state privacy laws. In the EU, Singapore, and Brazil, data protection law applies to the sponsor's own processing as well as the site's. In Brazil, the 2024 law on research with human beings now governs the protection of participants' personal data, with the LGPD applying where it is silent (Presidência da República, 2024, Art. 61).&lt;/p&gt;

&lt;p&gt;So it helps to record, for each copy of the data, who holds it and in what role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example fields
&lt;/h2&gt;

&lt;p&gt;Some of these belong on each participant record and some on each copy of the data. They're examples, not a complete schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;holder_role&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;On each copy: the organisation that holds it, the country it's established in, and its role, such as site, sponsor, CRO, or partner. Most of the fields below can only be read correctly alongside this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;jurisdiction&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The country where the participant enrolled. It decides which regime governs collection, but it doesn't settle every later use. GDPR also applies to processing in the context of a holder established in the EU, wherever the processing happens (European Parliament and Council, 2016, Art. 3(1)), so this field has to be read together with &lt;code&gt;holder_role&lt;/code&gt;. It also has to stay attached when data from several countries is pooled, since a pooled dataset that has lost its country column is hard to check against any of the rules that follow.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;legal_basis&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The basis on which the data is processed, which is often more than one, and differs by purpose.&lt;/p&gt;

&lt;p&gt;In the EU, the informed consent a trial requires is an ethical safeguard, and the European Data Protection Board says it must not be confused with consent as a legal basis under GDPR. Processing for safety reporting and record-keeping rests on legal obligation. Processing for the research itself can rest on explicit consent, a task in the public interest, or the sponsor's legitimate interests, combined with a condition in Article 9, and the Board says consent will not be the appropriate basis in most cases (EDPB, 2019). Member states may also add conditions for health and genetic data (European Parliament and Council, 2016, Art. 9(4)). In the US, a site discloses to the sponsor under a written authorisation that describes each purpose, or under a waiver approved by an institutional review board, and can disclose to an FDA-regulated sponsor for safety purposes such as adverse event reporting (HHS, n.d.a, §164.508; HHS, n.d.c, §164.512(b) and (i)).&lt;/p&gt;

&lt;p&gt;Singapore's PDPA requires consent unless a listed exception applies (AGC, n.d., s.13), and other written law prevails where it is inconsistent with the PDPA (AGC, n.d., s.4(6)(b)), so trial-specific rules can take precedence. Its research exception for using personal data requires, among other things, that the results won't be used to make any decision that affects the individual (AGC, n.d., Second Schedule, Part 2, Division 3), which suits later research better than the trial itself. In Brazil, the LGPD, which fills the gaps in the research law, allows sensitive data to be processed with specific, highlighted consent, or without consent where needed for a legal or regulatory obligation. Its research route is limited to Brazilian public bodies and non-profits established in Brazil whose mission includes research, which leaves most commercial sponsors relying on consent or legal obligation (Presidência da República, 2018, Arts. 5 and 11).&lt;/p&gt;

&lt;p&gt;Record the basis per purpose at enrolment, from the consent version the participant signed, so that each later use can be checked against it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;consent_scope&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The optional consents, separate from the main trial consent. The EU Clinical Trials Regulation lets a sponsor ask participants to agree to use of their data outside the protocol, exclusively for scientific purposes, and says that consent can be withdrawn at any time, without prejudice to data protection law (European Parliament and Council, 2014, Art. 28(2)). Other countries use similar optional consents for future research, sample storage, or sharing with partners. Each one is a yes, a no, or not asked, and it can change. A model-training project, a partner extract, or a pooled analysis should only draw on participants whose consent scope covers it, and the use still needs its own &lt;code&gt;legal_basis&lt;/code&gt; entry, because the Board treats this optional consent as an ethical safeguard rather than a GDPR legal basis (EDPB, 2019). Consent and lineage models like these are a regular part of the clinical data platforms our &lt;a href="https://www.sakurasky.com/data/" rel="noopener noreferrer"&gt;Data &amp;amp; AI practice&lt;/a&gt; works on.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;transfer_route&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;How the data may leave the country, and under what safeguard. All four regimes regulate how data leaves; none of them requires it to stay. GDPR allows transfers to countries the European Commission has found adequate, with appropriate safeguards, or in limited cases under a derogation (European Parliament and Council, 2016, Chapter V). LGPD allows transfers to adequate countries or with safeguards such as standard contractual clauses (Presidência da República, 2018, Art. 33). PDPA makes the transferring organisation ensure the data gets protection comparable to the Act's (AGC, n.d., s.26). HIPAA's Privacy Rule has no data-location rule. Separately, a US Justice Department rule bars US persons from some transactions, and restricts others, that give countries of concern access to bulk US sensitive personal data. The thresholds include personal health data on more than 10,000 US persons and genomic data on more than 100, and transactions involving bulk genomic and other 'omic data or biospecimens are prohibited (DOJ, n.d., §§202.205 and 202.303). There's an exemption for transactions ordinarily incident to and part of FDA-regulated clinical investigations (DOJ, n.d., §202.511), but reuse outside the trial may fall outside it.&lt;/p&gt;

&lt;p&gt;Each transfer, including a CRO statistician working from another country, needs a recorded route. The platform should be able to show, for any dataset, which participants' data left which country and on what basis. Our &lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-1/" rel="noopener noreferrer"&gt;Sovereignty Versus Efficiency&lt;/a&gt; post looked at residency as an architecture question across regulated industries, and participant data adds a further question about which uses each participant agreed to.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;identifiability&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Whether a given copy counts as personal data, which depends on the law and on who holds it. Under GDPR, pseudonymised data that could be attributed to a person using additional information is personal data (European Parliament and Council, 2016, Recital 26). The EU Court of Justice has since held, under the equivalent rules for EU institutions, that pseudonymised data isn't personal data in all cases and for every person, since pseudonymisation may prevent people other than the controller from identifying anyone (CJEU, 2025, para. 86). Singapore's PDPA counts data as personal if a person can be identified from it together with other information the organisation has or is likely to have access to (AGC, n.d., s.2). HIPAA treats data as de-identified when an expert determines the risk of re-identification is very small, or when a listed set of identifiers is removed and the covered entity has no actual knowledge that what's left could identify someone (HHS, n.d.b, §164.514(b)). That list includes all elements of dates except the year, so a coded trial dataset with visit dates isn't de-identified under the second method. LGPD treats anonymised data as outside the law unless the anonymisation can be reversed with reasonable effort (Presidência da República, 2018, Art. 12).&lt;/p&gt;

&lt;p&gt;So the same coded dataset can be personal data under one regime and not under another, and the answer can change when it's copied to a new holder. Set this field per copy, and record which participants' data trained or validated each model, so a later question about reuse can be answered.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;withdrawal_state&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;What still applies after a participant leaves. Every regime gives participants a way out, and they differ on what happens next. In the EU, the Board's view is that withdrawing consent stops the research based on that consent, and that where no other basis applies the data should be deleted, while processing for legal obligations such as safety reporting continues (EDPB, 2019), and the Clinical Trials Regulation keeps data obtained before withdrawal, without prejudice to GDPR (European Parliament and Council, 2014, Art. 28(3)). In the US, FDA policy is that data already collected about participants who leave a study is kept as part of the study data (FDA, 2008), and a site's HIPAA authorisation can be revoked in writing except where the site has already acted on it (HHS, n.d.a, §164.508(b)(5)). PDPA requires the organisation to stop using the data unless the law requires or authorises the use without consent (AGC, n.d., s.16(4)). LGPD keeps processing done before revocation valid while no deletion is requested (Presidência da República, 2018, Art. 8 §5).&lt;/p&gt;

&lt;p&gt;The field can record which uses must stop, which may continue, and whether deletion applies, so the platform can find every copy affected: analysis datasets, partner extracts, and the training data behind models. Keeping the evidence of those checks current as consents and regulations change is what &lt;a href="https://www.sakurasky.com/praxis/" rel="noopener noreferrer"&gt;Praxis&lt;/a&gt;, Sakura's compliance solution, is built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four regimes side by side
&lt;/h2&gt;

&lt;p&gt;A simplified reference. Each cell has exceptions, and the sources above give the detail. In the EU, member states may add conditions for health and genetic data. In Brazil, the 2024 research law applies first, with the LGPD filling gaps.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;EU&lt;/th&gt;
&lt;th&gt;US&lt;/th&gt;
&lt;th&gt;Singapore&lt;/th&gt;
&lt;th&gt;Brazil&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Basis&lt;/td&gt;
&lt;td&gt;Legal obligation for safety and records; explicit consent, public interest, or legitimate interests for research (EDPB)&lt;/td&gt;
&lt;td&gt;Site: authorisation or IRB waiver (HIPAA); sponsor: consent, FDA rules, and contracts&lt;/td&gt;
&lt;td&gt;Consent, or listed exceptions with conditions (PDPA)&lt;/td&gt;
&lt;td&gt;Research law first; under LGPD, specific consent or legal or regulatory obligation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transfer&lt;/td&gt;
&lt;td&gt;Adequacy, safeguards, or limited derogations (GDPR)&lt;/td&gt;
&lt;td&gt;No HIPAA location rule; DOJ bulk data rule, with a trial exemption&lt;/td&gt;
&lt;td&gt;Transferring organisation ensures comparable protection (PDPA)&lt;/td&gt;
&lt;td&gt;Adequacy or safeguards (LGPD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identifiers removed&lt;/td&gt;
&lt;td&gt;Depends on who holds it (GDPR; CJEU)&lt;/td&gt;
&lt;td&gt;De-identified by expert determination or by removing listed identifiers (HIPAA)&lt;/td&gt;
&lt;td&gt;Personal if identifiable with information the holder has or can access (PDPA)&lt;/td&gt;
&lt;td&gt;Not personal data unless reasonably reversible (LGPD)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Withdrawal&lt;/td&gt;
&lt;td&gt;Consent-based research stops, with deletion if no other basis; legal obligations continue&lt;/td&gt;
&lt;td&gt;Accrued data kept (FDA); site authorisation revocable&lt;/td&gt;
&lt;td&gt;Stop using unless the law permits (PDPA)&lt;/td&gt;
&lt;td&gt;Earlier processing valid unless deletion requested (LGPD)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Example fields like these help keep each participant's data current and checked before a transfer or a new use. Clear-cut cases can be handled by the platform, and the rest can go to someone who can decide.&lt;/p&gt;

&lt;p&gt;That closes the Pharma and Healthcare series. Part 1 covered custody of trial data and Part 2 the provenance of research data, and this post has looked at permission, meaning which uses each participant's data allows in each country. For the jurisdiction-by-jurisdiction mapping behind those fields, talk to our &lt;a href="https://www.sakurasky.com/grc/" rel="noopener noreferrer"&gt;GRC service&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Not legal advice. This article offers general commentary on the EU General Data Protection Regulation, the EU Clinical Trials Regulation, the US HIPAA Privacy Rule, FDA guidance, the US Justice Department's rule on access to sensitive personal data, Singapore's Personal Data Protection Act, and Brazil's research and data protection laws for a pharma and healthcare engineering audience. It is not legal advice and is not a substitute for advice from qualified counsel or qualified regulatory affairs professionals. Specific obligations depend on the nature of your clinical programme, the jurisdictions in which you operate, your sponsor and CRO agreements, and the regulators to which you are subject. Readers must obtain independent legal and regulatory advice on how these laws apply to their specific products and clinical operations.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Attorney-General's Chambers (AGC), n.d. &lt;em&gt;Personal Data Protection Act 2012.&lt;/em&gt; Singapore Statutes Online. Available at: &lt;a href="https://sso.agc.gov.sg/Act/PDPA2012?WholeDoc=1" rel="noopener noreferrer"&gt;https://sso.agc.gov.sg/Act/PDPA2012?WholeDoc=1&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;Court of Justice of the European Union (CJEU), 2025. &lt;em&gt;European Data Protection Supervisor v Single Resolution Board, Case C-413/23 P.&lt;/em&gt; Judgment of 4 September 2025, ECLI:EU:C:2025:645. Available at: &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:62023CJ0413" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:62023CJ0413&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;European Data Protection Board (EDPB), 2019. &lt;em&gt;Opinion 3/2019 concerning the Questions and Answers on the interplay between the Clinical Trials Regulation (CTR) and the General Data Protection Regulation (GDPR).&lt;/em&gt; Adopted 23 January 2019. Available at: &lt;a href="https://www.edpb.europa.eu/documents/legislative-opinion/opinion-32019-concerning-the-questions-and-answers-on-the-interplay_en" rel="noopener noreferrer"&gt;https://www.edpb.europa.eu/documents/legislative-opinion/opinion-32019-concerning-the-questions-and-answers-on-the-interplay_en&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;European Parliament and Council, 2014. &lt;em&gt;Regulation (EU) No 536/2014 of the European Parliament and of the Council of 16 April 2014 on clinical trials on medicinal products for human use.&lt;/em&gt; Official Journal of the European Union, L 158, 27 May. Available at: &lt;a href="https://eur-lex.europa.eu/eli/reg/2014/536/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2014/536/oj&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;European Parliament and Council, 2016. &lt;em&gt;Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation).&lt;/em&gt; Official Journal of the European Union, L 119, 4 May, pp. 1-88. Available at: &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2016/679/oj&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;Presidência da República, 2018. &lt;em&gt;Lei nº 13.709, de 14 de agosto de 2018: Lei Geral de Proteção de Dados Pessoais (LGPD).&lt;/em&gt; Brasília. Available at: &lt;a href="https://www.planalto.gov.br/ccivil_03/_ato2015-2018/2018/lei/l13709.htm" rel="noopener noreferrer"&gt;https://www.planalto.gov.br/ccivil_03/_ato2015-2018/2018/lei/l13709.htm&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;Presidência da República, 2024. &lt;em&gt;Lei nº 14.874, de 28 de maio de 2024: dispõe sobre a pesquisa com seres humanos e institui o Sistema Nacional de Ética em Pesquisa com Seres Humanos.&lt;/em&gt; Brasília. Available at: &lt;a href="https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2024/lei/L14874.htm" rel="noopener noreferrer"&gt;https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2024/lei/L14874.htm&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;US Department of Health and Human Services (HHS), n.d.a. &lt;em&gt;45 CFR 164.508: Uses and disclosures for which an authorization is required.&lt;/em&gt; Electronic Code of Federal Regulations. Available at: &lt;a href="https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.508" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.508&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;US Department of Health and Human Services (HHS), n.d.b. &lt;em&gt;45 CFR 164.514: Other requirements relating to uses and disclosures of protected health information.&lt;/em&gt; Electronic Code of Federal Regulations. Available at: &lt;a href="https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.514" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.514&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;US Department of Health and Human Services (HHS), n.d.c. &lt;em&gt;45 CFR 164.512: Uses and disclosures for which an authorization or opportunity to agree or object is not required.&lt;/em&gt; Electronic Code of Federal Regulations. Available at: &lt;a href="https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.512" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-E/section-164.512&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;US Department of Justice (DOJ), n.d. &lt;em&gt;28 CFR Part 202: Access to U.S. Sensitive Personal Data and Government-Related Data by Countries of Concern or Covered Persons.&lt;/em&gt; Electronic Code of Federal Regulations. Available at: &lt;a href="https://www.ecfr.gov/current/title-28/chapter-I/part-202" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-28/chapter-I/part-202&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

&lt;p&gt;US Food and Drug Administration (FDA), 2008. &lt;em&gt;Guidance for Sponsors, Clinical Investigators, and IRBs: Data Retention When Subjects Withdraw from FDA-Regulated Clinical Trials.&lt;/em&gt; October 2008. Available at: &lt;a href="https://www.fda.gov/regulatory-information/search-fda-guidance-documents/data-retention-when-subjects-withdraw-fda-regulated-clinical-trials" rel="noopener noreferrer"&gt;https://www.fda.gov/regulatory-information/search-fda-guidance-documents/data-retention-when-subjects-withdraw-fda-regulated-clinical-trials&lt;/a&gt; [Accessed 6 October 2026].&lt;/p&gt;

</description>
      <category>pharma</category>
      <category>privacy</category>
      <category>compliance</category>
    </item>
    <item>
      <title>Your Lab Notebook Is a Data Product</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Mon, 05 Oct 2026 07:38:41 +0000</pubDate>
      <link>https://dev.to/sakurasky/your-lab-notebook-is-a-data-product-hbd</link>
      <guid>https://dev.to/sakurasky/your-lab-notebook-is-a-data-product-hbd</guid>
      <description>&lt;p&gt;When your corporate development team has a term sheet for out-licensing a preclinical programme, the partner's diligence list is likely to run to several hundred lines. While most will be routine, a handful will land on the research organisation, and they tend to look like these four, paraphrased from the kind that appear in life sciences licensing and M&amp;amp;A diligence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;14.3 Provide the primary data behind the in vivo efficacy figures in the data room summary, including raw instrument output.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;14.7 Confirm the analysis can be reproduced: methods, software and versions used, and any exclusions applied.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;14.9 For each key experiment, identify who generated the data, when, and under which employment, CRO, or collaboration agreement, with the supporting records.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;14.12 List any third-party data in the package (CROs, academic collaborators, material transfer agreements, licensed datasets, and any government funding) and the terms under which it can be used and transferred.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The science is rarely what slows these requests down. The effort goes into answering them in a way the partner will rely on when it comes to price, because anything it can't verify is likely to come back as a warranty or a price adjustment. The answers usually exist, spread across electronic lab notebooks (ELNs), instrument PCs, a scientific data management system (SDMS), departmental shares, CRO portals, and the memories of scientists, some of whom now work on other programmes.&lt;/p&gt;

&lt;p&gt;Research data in most pharma and biotech organisations is kept in several ways at once: by individual scientists in the way that suits their work, in shared drives and an ELN under lab-level rules, and, less often, as versioned, described, and owned datasets built for people who weren't there when they were generated. Diligence tests all of them, because it asks the questions day-to-day research rarely does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request 14.3: the raw data
&lt;/h2&gt;

&lt;p&gt;For a typical in vivo efficacy study, the raw data is mostly where the team left it. Caliper and body-weight readings sit in a spreadsheet, imaging files on the instrument PC or an external drive, and the processed numbers in a workbook named in a way that made sense at the time. The ELN entry records the protocol and attaches the final figure as an image. An SDMS may have captured the imaging output, but nothing records which files fed which figure, so the link between the summary in the data room and the data underneath it depends on folder conventions and on the people who remember them.&lt;/p&gt;

&lt;p&gt;What would have answered 14.3 is an identifier for the study, with the raw output, the processed data, and each figure linked to one another and to the ELN entry that describes the experiment. With that in place, the response is an export of a defined set of records to the data room, and nothing has to be assembled for the occasion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request 14.7: reproducing the result
&lt;/h2&gt;

&lt;p&gt;This is often the request that takes longest. The FAIR principles, a widely adopted framework for research data stewardship, ask for data to be findable, accessible, interoperable, and reusable, by machines as well as people, and for data and metadata to be associated with detailed provenance (Wilkinson et al., 2016, R1.2). For an analysis, provenance means more than the files: which script, which version, which parameters, and which animals were excluded and why.&lt;/p&gt;

&lt;p&gt;The method is usually well written up in the ELN. The code that ran it is more often in an analyst's personal repository, and exclusion decisions made in a lab meeting may be recorded only as rows deleted from a spreadsheet. Reproducing the result then means finding the analyst, recovering the script version, and reconstructing the reasoning for the exclusions from meeting notes.&lt;/p&gt;

&lt;p&gt;A research data platform built for this stores the script and its version with the output it produced, records parameters as data, and logs each exclusion with its reason. This is the same engineering any production data platform uses, applied to lab data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request 14.9: who generated it, and under what agreement
&lt;/h2&gt;

&lt;p&gt;This request is about chain of title. A partner wants to know that the organisation owns what it's licensing, which means knowing who generated each key dataset and under which agreement: an employee's contract, a CRO's statement of work, or a collaboration agreement with its own terms on ownership.&lt;/p&gt;

&lt;p&gt;The lab notebook's role in research IP has changed. Since the US moved to a first-inventor-to-file system for applications filed on or after 16 March 2013, a dated notebook entry no longer wins a priority contest against someone who filed first (USPTO, n.d.). Notebooks still matter as evidence of who conceived an invention, in disputes with collaborators, in derivation proceedings, which must be supported by substantial evidence (USPTO, n.d.), and for patents filed under the earlier rules that remain in force. Inventorship is a separate question from who generated the data, and a diligence team will usually ask about it separately.&lt;/p&gt;

&lt;p&gt;The NIH's data management and sharing policy, which applies to research NIH funds or conducts, draws a line that's useful well beyond its scope. It defines scientific data as data of sufficient quality to validate and replicate research findings, and excludes laboratory notebooks, preliminary analyses, and communications with colleagues from that definition (NIH, n.d.). Many organisations haven't drawn that line internally, so data gets pasted into notebook entries and notebook pages get photographed into data folders. Keeping them linked but distinct, with the ELN entry recording who did what and why and the dataset recording what was produced and by which instrument or provider, lets 14.9 be answered with a query across the two records. In GLP studies, original observations recorded in a notebook are themselves raw data under FDA rules (FDA, n.d., §58.3(k)), so the split applies mainly to discovery and other non-GLP work, which is where most efficacy data sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request 14.12: whose data it is
&lt;/h2&gt;

&lt;p&gt;The last request can change the deal terms. A preclinical package often includes data generated by CROs, material received under transfer agreements, academic collaborations, and licensed third-party datasets, each under its own agreement. FAIR asks for data to be released with a clear and accessible data usage licence (Wilkinson et al., 2016, R1.1), and that's a sensible standard inside a company as well as outside it. Data from an NIH-funded academic collaborator may also come with sharing commitments made in that collaborator's data management plan (NIH, n.d.).&lt;/p&gt;

&lt;p&gt;Notebooks and shared drives rarely carry those terms. The CRO's study report is in the archive, and the contract that says who owns the underlying data is with legal. When source and usage terms are carried as metadata, the package can be filtered to what's transferable before the data room opens. FAIR's principle that metadata should stay accessible even when the data itself is no longer available (Wilkinson et al., 2016, A2) matters here as well, because an agreement may require data to be returned or deleted while the record that it existed has to remain.&lt;/p&gt;

&lt;p&gt;This is the argument &lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-4/" rel="noopener noreferrer"&gt;Telemetry Is Becoming the Business&lt;/a&gt; made about operational data, applied to the bench: a by-product of the work that needs an owner, a contract, and a structure once someone outside the lab relies on it. Sakura's &lt;a href="https://www.sakurasky.com/data/" rel="noopener noreferrer"&gt;Data &amp;amp; AI practice&lt;/a&gt; builds research data platforms on top of the ELN and instrument estate a lab already runs, which is usually where the pharma data architecture work starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works at the bench
&lt;/h2&gt;

&lt;p&gt;None of this works unless scientists use it, and research culture has good reasons for its habits. The line tends to fall in predictable places.&lt;/p&gt;

&lt;p&gt;Capture that happens without the scientist is usually welcome. Instruments that write raw output straight to a managed store, with the run's metadata attached, remove work. So does an ELN template that pulls sample and instrument identifiers from the systems that already know them. Anything that removes a manual copy step tends to be adopted quickly.&lt;/p&gt;

&lt;p&gt;Structure is accepted when it pays the scientist back. Someone who can find an older assay dataset in seconds, or rerun a colleague's analysis without a meeting, sees the point of identifiers and versioning. A form asking for fifteen metadata fields for someone else's benefit gets skipped.&lt;/p&gt;

&lt;p&gt;What doesn't work is a governance process that slows exploratory work. Most experiments are exploratory, and many are superseded, so treating every early run as a curated asset costs effort the science can't spare. The workable approach is for automated raw capture to run for every experiment, and for curation, review, and ownership to start at a defined promotion point: when a result is going to be presented, included in an invention disclosure, or built on. The notebook stays the scientist's space, and the data product is what the organisation builds from the experiments that matter.&lt;/p&gt;

&lt;p&gt;That promotion step, with its integrations and access rules, has to be run as instruments, partners, and programmes change. Our &lt;a href="https://www.sakurasky.com/managed-services/" rel="noopener noreferrer"&gt;Managed Services&lt;/a&gt; team keeps it running for research organisations. Done well, the scientist notices fewer copy steps, and the diligence team notices nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;National Institutes of Health (NIH), n.d. &lt;em&gt;Data Management and Sharing Policy Overview.&lt;/em&gt; NIH Grants and Funding. Available at: &lt;a href="https://grants.nih.gov/policy-and-compliance/policy-topics/sharing-policies/dms/policy-overview" rel="noopener noreferrer"&gt;https://grants.nih.gov/policy-and-compliance/policy-topics/sharing-policies/dms/policy-overview&lt;/a&gt; [Accessed 5 October 2026].&lt;/p&gt;

&lt;p&gt;United States Patent and Trademark Office (USPTO), n.d. &lt;em&gt;America Invents Act (AIA) Frequently Asked Questions.&lt;/em&gt; USPTO. Available at: &lt;a href="https://www.uspto.gov/patents/laws/america-invents-act-aia/america-invents-act-aia-frequently-asked" rel="noopener noreferrer"&gt;https://www.uspto.gov/patents/laws/america-invents-act-aia/america-invents-act-aia-frequently-asked&lt;/a&gt; [Accessed 5 October 2026].&lt;/p&gt;

&lt;p&gt;US Food and Drug Administration (FDA), n.d. &lt;em&gt;21 CFR 58.3: Definitions (Good Laboratory Practice for Nonclinical Laboratory Studies).&lt;/em&gt; Electronic Code of Federal Regulations. Available at: &lt;a href="https://www.ecfr.gov/current/title-21/chapter-I/subchapter-A/part-58/subpart-A/section-58.3" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-21/chapter-I/subchapter-A/part-58/subpart-A/section-58.3&lt;/a&gt; [Accessed 5 October 2026].&lt;/p&gt;

&lt;p&gt;Wilkinson, M.D., Dumontier, M., Aalbersberg, I.J. et al., 2016. The FAIR Guiding Principles for scientific data management and stewardship. &lt;em&gt;Scientific Data&lt;/em&gt;, 3, 160018. Available at: &lt;a href="https://doi.org/10.1038/sdata.2016.18" rel="noopener noreferrer"&gt;https://doi.org/10.1038/sdata.2016.18&lt;/a&gt; [Accessed 5 October 2026].&lt;/p&gt;

</description>
      <category>pharma</category>
      <category>data</category>
      <category>governance</category>
    </item>
    <item>
      <title>Chain of Custody for Trial Data</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Fri, 02 Oct 2026 07:04:35 +0000</pubDate>
      <link>https://dev.to/sakurasky/chain-of-custody-for-trial-data-57md</link>
      <guid>https://dev.to/sakurasky/chain-of-custody-for-trial-data-57md</guid>
      <description>&lt;p&gt;Between two transfers from the central laboratory, one LDL cholesterol result in a cardiovascular trial fell from 3.1 to 2.4 mmol/L. The value was participant 1047's reading at visit 6, an input to the trial's primary endpoint, the percent change in LDL-C from baseline. Below is everything the sponsor's clinical data repository recorded about the change.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row&lt;/th&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Old&lt;/th&gt;
&lt;th&gt;New&lt;/th&gt;
&lt;th&gt;User (role, organisation)&lt;/th&gt;
&lt;th&gt;Reason recorded&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-03-03 09:12&lt;/td&gt;
&lt;td&gt;Value loaded&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;lab_transfer (system, central lab)&lt;/td&gt;
&lt;td&gt;Transfer 14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2026-03-19 09:10&lt;/td&gt;
&lt;td&gt;Value updated&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;td&gt;lab_transfer (system, central lab)&lt;/td&gt;
&lt;td&gt;Transfer 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2026-03-19 09:31&lt;/td&gt;
&lt;td&gt;Discrepancy raised&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;recon_check (system, CRO)&lt;/td&gt;
&lt;td&gt;Differs from previous transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2026-03-24 11:40&lt;/td&gt;
&lt;td&gt;Discrepancy closed&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;dm_03 (data manager, CRO)&lt;/td&gt;
&lt;td&gt;Confirmed with lab&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Simplified from load histories we've reviewed. The trial, identifiers, and values are invented.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A reconciliation check did its job and flagged the change. What the repository can't show is why the laboratory's number moved, or what the data manager saw before closing row 4. The answer exists. The lab's triglyceride result for that sample was too high for a calculated LDL-C, so the lab measured LDL-C directly and issued a revised value. A lab project manager explained this in an email to the contract research organisation (CRO). The data manager read the email, checked the lab's report, and closed the discrepancy. The lab, the CRO, and data management each followed their own procedures. But the evidence that turns row 4 from an assertion into a fact sits in a laboratory information system, an inbox, and the data manager's memory.&lt;/p&gt;

&lt;p&gt;That matters most in a sponsor inspection, often long after the people who closed row 4 have moved on. A value that feeds the primary endpoint dropped by more than a fifth, and the only explanation on record is "Confirmed with lab."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-3/" rel="noopener noreferrer"&gt;Evidence Versus Speed&lt;/a&gt; argued for capturing lineage at the moment data is written. Trials complicate that advice, because most of the writes behind one endpoint value happen in systems the sponsor doesn't run. Chain of custody, borrowed from forensics, is a better frame here: evidence keeps its value only if every handover leaves a record of who passed what to whom, and in what condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custody inside one system
&lt;/h2&gt;

&lt;p&gt;Inside each system, custody is usually in good shape. 21 CFR Part 11 requires secure, computer-generated, time-stamped audit trails that independently record operator entries and actions that create, modify, or delete electronic records, without obscuring what was there before, and retained for as long as the records themselves (FDA, n.d., §11.10(e)). It requires authority checks so only authorised individuals can alter a record (§11.10(g)), and signatures that show the signer's printed name, the time, and the meaning of the signature (§11.50). The repository's audit trail above captures what those sections ask for.&lt;/p&gt;

&lt;p&gt;ICH E6(R3), adopted in January 2025, sets a higher bar for data of higher criticality, such as endpoints. It asks for systems that document changes including, where appropriate, the reason for the change, and for audit trails that are interpretable and can support review (ICH, 2025, §4.2.2). Its section on corrections says a correction should be attributed to the person or system making it, justified, and supported by source records from around the time of the original entry (§4.2.4). The EMA's guideline on computerised systems in clinical trials is specific about what an audit trail should show: what changed, by whom (username, role, and organisation), when, and where applicable why (EMA, 2023, §6.2.1).&lt;/p&gt;

&lt;p&gt;Organisation is the field that marks a handover, and it's where most audit trails stop. Row 2 says the central lab changed the value. It can't say why, because the reason lives in the lab's own system. Row 4 records a reason, but "Confirmed with lab" points to a conversation, not a record. In the programmes we see, trial data lineage problems usually start this way, with an audit trail that is complete and accurate and still doesn't lead to the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custody between systems
&lt;/h2&gt;

&lt;p&gt;A single endpoint value can pass through three or four organisations before it reaches an analysis dataset. This one began in the lab's information system and travelled as a cumulative transfer file under a data transfer agreement. It was loaded into the sponsor's repository by the CRO, reconciled, and would later move again into the datasets used for analysis. Lab results like this one are usually kept from site staff after randomisation in a lipid trial, since the drop would reveal treatment, so the chain runs between lab, CRO, and sponsor rather than through the site.&lt;/p&gt;

&lt;p&gt;The guidance is direct about these hops. E6(R3) asks for validated processes or reconciliation to make sure data transferred between computerised systems, including relevant metadata, keeps its integrity, and for transfers and migrations to be documented to ensure traceability (ICH, 2025, §4.2.5). The EMA requires every transfer during a trial to be pre-specified and validated, and expects transferred data and their audit trails to remain continuously accessible (EMA, 2023, §6.1.2). In a sponsor inspection, the FDA says it will generally focus on, among other things, the processes that keep data from being altered in value or meaning, including during transfer to durable electronic repositories, along with change control and contracts with service providers (FDA, 2024, Q8).&lt;/p&gt;

&lt;p&gt;E6(R3) does require interfaces between systems to be validated (ICH, 2025, §4.3.4(e)). But a validated interface shows the file arrived intact. It doesn't show why its contents differ from the last one. The transfer for participant 1047 was validated, and the lab's revision followed the lab's own procedures, yet neither record pointed at the data point it changed. The chain recorded the files and none of the decisions behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custody after the trial
&lt;/h2&gt;

&lt;p&gt;The last handover is the one teams plan least: from live systems to the archive, as trials finish, CRO contracts end, and vendor systems are decommissioned. The EMA expects inspectors to keep direct, read-only access to a decommissioned system's data, and expects archived formats to allow the database to be restored with its metadata (audit trail, event logs, edit checks, queries, and user logs). Where that isn't possible, data and metadata should be kept as dynamic files, and static copies of dynamic data aren't considered adequate (EMA, 2023, §4.11, §6.12). FDA guidance makes the same point when a hosted-system contract ends: the sponsor should make sure the metadata is obtained, retained, and linkable to each data element (FDA, 2024). E6(R3) asks for data and metadata to stay retrievable and readable, and protected from unauthorised access and alteration, for the whole retention period (ICH, 2025, §4.2.7).&lt;/p&gt;

&lt;p&gt;For participant 1047, this is where the chain could break for good. If the lab's direct-measurement record and the email behind row 4 aren't in the sponsor's archive when the lab and CRO contracts close, row 4 can only be explained from memory. A programme that runs across several labs and CROs repeats that risk at every exit, which is why audit-ready clinical data at closeout depends on planning those exits at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  A receipt at every handover
&lt;/h2&gt;

&lt;p&gt;None of this needs a new clinical data platform. Each handover needs to produce a record the trial keeps, and four changes do most of the work.&lt;/p&gt;

&lt;p&gt;Each inbound transfer is logged as an event in its own right, with the file's hash, the specification version, the sending system, and a link to the reconciliation that checked it. A change that starts outside the repository carries its source identifier, such as the lab's accession number for the direct measurement. Correspondence that justifies a change is filed in the trial master file and linked to the data point. And data transfer agreements require those records to reach the sponsor's archive with each transfer.&lt;/p&gt;

&lt;p&gt;The core component is a lineage store keyed on the data point, fed by events from each system and queryable by anyone with the right access. The unambiguous timestamps E6(R3) asks for, with UTC as its example (ICH, 2025, §4.2.2(d)), make it possible to put events from different organisations in one order. That store is itself a computerised system in scope for validation and access control (ICH, 2025, §4.3.4), so it belongs in the validation plan from the start. Sakura's &lt;a href="https://www.sakurasky.com/data/" rel="noopener noreferrer"&gt;Data &amp;amp; AI practice&lt;/a&gt; builds this layer for sponsors and CROs, usually starting from the transfers they already receive.&lt;/p&gt;

&lt;p&gt;With those records in place, row 4 reads differently. The closure links to the lab's revised result and the reason for it, the result links to the transfer that carried it, and the transfer links to the reconciliation that caught it. An inspector can follow the chain outward from the data point using records the sponsor already holds. The risk-based review E6(R3) asks for (ICH, 2025, §4.2.3) gets easier too: when central monitoring flags a run of revised lab values from one transfer, the reasons are already linked.&lt;/p&gt;

&lt;p&gt;The cheapest place to add a receipt is a contract that hasn't been signed yet. A data transfer agreement can name the events a lab or CRO must deliver, the identifiers they carry, and where they go when the contract ends, while retrofitting the same terms at closeout means renegotiating with a vendor who is already leaving. Sakura's &lt;a href="https://www.sakurasky.com/grc/" rel="noopener noreferrer"&gt;GRC service&lt;/a&gt; helps sponsor teams write those terms and set up the checks that confirm they're met.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Not legal advice. This article offers general commentary on FDA 21 CFR Part 11 and ICH E6(R3) Good Clinical Practice for a pharma and healthcare engineering audience. It is not legal advice and is not a substitute for advice from qualified counsel or qualified regulatory affairs professionals. Specific obligations depend on the nature of your clinical programme, the jurisdictions in which you operate, your sponsor and CRO agreements, and the inspection regime to which you are subject. Readers must obtain independent legal and regulatory advice on how these regulations apply to their specific products and clinical operations.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;European Medicines Agency (EMA), 2023. &lt;em&gt;Guideline on computerised systems and electronic data in clinical trials.&lt;/em&gt; EMA/INS/GCP/112288/2023. European Medicines Agency. Available at: &lt;a href="https://www.ema.europa.eu/en/documents/regulatory-procedural-guideline/guideline-computerised-systems-and-electronic-data-clinical-trials_en.pdf" rel="noopener noreferrer"&gt;https://www.ema.europa.eu/en/documents/regulatory-procedural-guideline/guideline-computerised-systems-and-electronic-data-clinical-trials_en.pdf&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

&lt;p&gt;International Council for Harmonisation (ICH), 2025. &lt;em&gt;ICH Harmonised Guideline: Guideline for Good Clinical Practice E6(R3).&lt;/em&gt; Adopted 6 January 2025. ICH. Available at: &lt;a href="https://database.ich.org/sites/default/files/ICH_E6(R3)_Step4_FinalGuideline_2025_0106.pdf" rel="noopener noreferrer"&gt;https://database.ich.org/sites/default/files/ICH_E6(R3)_Step4_FinalGuideline_2025_0106.pdf&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

&lt;p&gt;US Food and Drug Administration (FDA), n.d. &lt;em&gt;21 CFR Part 11: Electronic Records; Electronic Signatures.&lt;/em&gt; Electronic Code of Federal Regulations. Available at: &lt;a href="https://www.ecfr.gov/current/title-21/chapter-I/subchapter-A/part-11" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-21/chapter-I/subchapter-A/part-11&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

&lt;p&gt;US Food and Drug Administration (FDA), 2024. &lt;em&gt;Electronic Systems, Electronic Records, and Electronic Signatures in Clinical Investigations: Questions and Answers. Guidance for Industry.&lt;/em&gt; Procedural, Revision 1, October 2024. FDA. Available at: &lt;a href="https://www.fda.gov/regulatory-information/search-fda-guidance-documents/electronic-systems-electronic-records-and-electronic-signatures-clinical-investigations-questions" rel="noopener noreferrer"&gt;https://www.fda.gov/regulatory-information/search-fda-guidance-documents/electronic-systems-electronic-records-and-electronic-signatures-clinical-investigations-questions&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

</description>
      <category>pharma</category>
      <category>compliance</category>
      <category>data</category>
    </item>
    <item>
      <title>Legacy Modernisation That Meets the Audit Bar</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Thu, 01 Oct 2026 16:03:43 +0000</pubDate>
      <link>https://dev.to/sakurasky/legacy-modernisation-that-meets-the-audit-bar-goi</link>
      <guid>https://dev.to/sakurasky/legacy-modernisation-that-meets-the-audit-bar-goi</guid>
      <description>&lt;p&gt;The audit manager had a sample of sixty benefit payments and a question about the forty-first. The department had replaced its benefits system partway through the year. The new platform was running well and the old one, which had been in service since before most of the team joined, had been switched off. The audit was for the annual accounts, and the conversation went like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The post-modernisation audit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Audit manager:&lt;/strong&gt; Sample item 41. The payment went out after cutover, but the award was set up on the old system. I'll recalculate it myself, so I need the circumstances and rates that applied on each date, and the evidence for each change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transformation lead:&lt;/strong&gt; The new platform has the award as it stood at cutover, and every change and payment since. That's all on screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit manager:&lt;/strong&gt; The award changed twice before cutover, once for a change of circumstances and once on appeal. Where are those?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transformation lead:&lt;/strong&gt; We migrated the current state of each claim as an opening position. The history went into an archive extract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit manager:&lt;/strong&gt; Before I rely on the extract, how do you know it's complete and accurate?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transformation lead:&lt;/strong&gt; We reconciled total caseload and total weekly liability between the two systems. They matched to the penny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit manager:&lt;/strong&gt; That tells me nothing went missing in aggregate. It doesn't tell me this claim's history is right. Can I see the change history for it, with who made each change and why?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transformation lead:&lt;/strong&gt; The officer IDs came across. The reason codes lived in a lookup table on the old system, so some are just numbers now. And the links to the case files used the old system's IDs, which we didn't migrate, so I can't point you to the appeal decision from here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit manager:&lt;/strong&gt; Who has write access to the archive, and is that access logged?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transformation lead:&lt;/strong&gt; It's in a storage bucket with restricted access. I'd have to check the logging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit manager:&lt;/strong&gt; If I can't test the history on this claim, I can't test it on any migrated claim, and that's most of the caseload. I'll need to take that to my director, because it may affect the opinion.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Composite of several programmes; roles, not people.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The new platform works, and none of the audit manager's questions were about that. She was asking whether the department could show, for one specific claim, what was decided, by whom, on what evidence, and that the record hadn't changed since. The old system could answer, clumsily, because it kept a journal of every change to a claim and linked each one to its case file. The modernisation carried the functionality across and left most of that behind.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-3/" rel="noopener noreferrer"&gt;Evidence Versus Speed&lt;/a&gt; made the general case that evidence has to be produced by the systems themselves, not assembled afterwards. This post is about what happens to that evidence when the system holding it is replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the standard migration playbook falls short
&lt;/h2&gt;

&lt;p&gt;The cost-led playbook for legacy system replacement, which is as common in government as in industry, is sound for what it's designed to do. Move the current state, keep a copy of the history somewhere cheap, prove the new system works through testing and a parallel run, then switch the old one off to stop paying for it. Success is measured by cutover, feature parity, and the cost line that drops when the legacy contract ends.&lt;/p&gt;

&lt;p&gt;The auditor's evidence standard is the same one a commercial auditor applies. The INTOSAI principles that supreme audit institutions draw on say that, in financial audits, the requirements on the auditor are the same as under the international auditing standards. They ask for evidence that is sufficient to persuade a knowledgeable person and appropriate, meaning relevant, valid, and reliable, and they list recalculation and reperformance among the ways to get it. If the evidence an auditor can't obtain could be material, the opinion is qualified, and if it's also pervasive, the auditor disclaims (INTOSAI, 2019). What government audit adds is regularity, testing whether transactions comply with the laws and authorities that govern them (INTOSAI, 2019). For a benefits payment, that means showing the award followed the legislation on the dates that mattered, which is impossible without the history.&lt;/p&gt;

&lt;p&gt;Records law adds a second layer. In the US, federal agencies running electronic recordkeeping must keep audit trails that track use of records, and must have procedures to migrate records together with their associated metadata when technology changes. The same rule says backups don't count as a recordkeeping system (National Archives and Records Administration, n.d.). An archive extract in a storage bucket can easily end up closer to a backup than a record. The UK's National Archives, in guidance written for document and records management systems, warns that audit trails may not export at all, in which case they have to be kept accessible outside the system. It also warns that dates such as a record's creation date can be reset to the date of migration (The National Archives, 2017).&lt;/p&gt;

&lt;p&gt;Records managers know all of this. The trouble is that it often sits outside the legacy modernisation plan. The US Government Accountability Office's review of the most critical federal legacy systems treated planned disposition of the old system as one of three minimum elements of a modernisation plan, alongside milestones and a description of the work, and found that most of the plans it examined didn't fully cover all three (Government Accountability Office, 2025).&lt;/p&gt;

&lt;h2&gt;
  
  
  The engineering disciplines that must deliver for audit
&lt;/h2&gt;

&lt;p&gt;An audit-ready modernisation treats evidence as a workstream with its own requirements, tests, and sign-off, alongside functionality. In practice that comes down to a handful of disciplines.&lt;/p&gt;

&lt;p&gt;Migrate the history as data, because this is where government data lineage is either carried across or lost. The change journal, with officer, timestamp, reason, and the before and after values, moves into the new estate as structured records that stay linked to the claim they belong to. Lookup tables travel with it, so a reason code still means something on the other side, and so do the links to case files and decision notices. Original dates are carried as data, never inferred from the load.&lt;/p&gt;

&lt;p&gt;Reconcile at two levels. Control totals show nothing went missing. A record-by-record comparison of the old and new positions shows each record is right, and the comparison itself is kept, with every difference explained, because the reconciliation run is evidence an auditor will want to see.&lt;/p&gt;

&lt;p&gt;Keep what a recalculation needs. Auditors recalculate entitlement from the legislation, the rates, and the claimant's circumstances, so the essentials are dated rate tables and the inputs to each decision. Where the new platform holds its rules as versioned, dated artefacts, past decisions can also be explained in the system's own terms. Keeping the old calculation runnable in a read-only form is a useful extra for claims likely to be revisited, not the core.&lt;/p&gt;

&lt;p&gt;Keep the parallel-run results as a record. When both systems calculate the same claims for a period, the comparison is evidence, including the cases where they disagreed and how each was resolved.&lt;/p&gt;

&lt;p&gt;Decommission last, against a checklist agreed with the people responsible for audit and records. The National Archives recommends agreeing clear acceptance criteria with suppliers before a migration starts (The National Archives, 2017). Evidence criteria belong on that list, and they're easiest to agree while the old system is still there to test against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence layer that moves with the workload
&lt;/h2&gt;

&lt;p&gt;Those disciplines work best with an architectural home of their own: an evidence layer that sits beside the application rather than inside it.&lt;/p&gt;

&lt;p&gt;Every consequential action on a claim (an award, a change of circumstances, a payment, or an override) writes a record to a store that the application can add to but not edit. Each record carries who acted, under what authority, on which evidence, and the original timestamp. Records are hash-chained, and the latest chain hash is signed at intervals and stored somewhere the store's own operators can't write, so a later change, even by an administrator, shows up when the chain is checked. Corrections are new entries that point to the record they correct. Access to the store is controlled and logged, which is the audit-trail requirement records law already sets. Disposal is designed in from the start too, because records schedules require eligible records to be deleted (National Archives and Records Administration, n.d.). One way is to encrypt each claim's records under its own key and destroy the key when the retention period ends, so the store can meet its disposal schedule without breaking the chain.&lt;/p&gt;

&lt;p&gt;Migration then means loading history into the same layer with its provenance marked: which system it came from, when, and which reconciliation run verified it. An auditor can follow sample item 41 from its first award to the latest payment, with a marker showing exactly where the line between systems fell and which run verified the crossing. The chain only protects history from the moment it's loaded, so whether the history was right before then still rests on the reconciliation and on the old system's controls. That's why the reconciliation is kept.&lt;/p&gt;

&lt;p&gt;The layer is also built to outlive the application. Benefits platforms get replaced, and in many departments the next replacement is already being planned. If the evidence lives inside the application, every modernisation has to solve this problem again. If it lives in its own layer, with its own retention rules and access controls, on whichever public sector cloud or data centre the department uses, the next migration moves the application and leaves the evidence where it is.&lt;/p&gt;

&lt;p&gt;A lot of government IT transformation is still to come. In its report on government cyber resilience, the UK National Audit Office found departments still running hundreds of ageing legacy systems, with no fully funded remediation plans for half of them (National Audit Office, 2025). The GAO's list of the most critical federal systems includes several it describes as decades old (Government Accountability Office, 2025). We build the evidence layer into the first wave of a government cloud migration, before go-live rather than after it, as part of Sakura's &lt;a href="https://www.sakurasky.com/cloud/" rel="noopener noreferrer"&gt;Cloud practice&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good looks like
&lt;/h2&gt;

&lt;p&gt;Same sample, a different programme: item 41 again, this time where the history was migrated into an evidence layer. The audit manager sees both pre-cutover changes, with officers, readable reasons, original dates, and links to the case file and appeal decision. A provenance record ties them to a named reconciliation run, and the rates in force on each date are on file. She recalculates the award and gets the same figure. The migration is still reviewed as a significant change in the year, but against a reconciliation the department already holds, not one it has to rebuild under pressure.&lt;/p&gt;

&lt;p&gt;Evidence Versus Speed traced the same move in clinical research and in the audit profession's own standards, from describing controls to producing evidence for a specific item on demand. Benefits audit has asked for evidence item by item for a long time. Modern platforms make that evidence cheap to keep, as long as the modernisation plans for it.&lt;/p&gt;

&lt;p&gt;It does cost something. Migrating history properly takes longer than migrating current state, record-level reconciliation is harder than totals, and an evidence layer is another system to run. Those costs are usually smaller than rebuilding lost history once the old system has gone, and they're easiest to fund at the start, which is why evidence requirements belong in the business case and the supplier's acceptance criteria.&lt;/p&gt;

&lt;p&gt;Before the old system is switched off, pick one claim that crosses the cutover and try to evidence it end to end. If you want a second pair of hands on that test, our &lt;a href="https://www.sakurasky.com/grc/" rel="noopener noreferrer"&gt;GRC service&lt;/a&gt; has run it on live programmes, and the gaps it turns up become the programme's evidence requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Government Accountability Office, 2025. &lt;em&gt;Information Technology: Agencies Need to Plan for Modernizing Critical Decades-Old Legacy Systems.&lt;/em&gt; GAO-25-107795. US Government Accountability Office. Available at: &lt;a href="https://www.gao.gov/products/gao-25-107795" rel="noopener noreferrer"&gt;https://www.gao.gov/products/gao-25-107795&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

&lt;p&gt;INTOSAI, 2019. &lt;em&gt;ISSAI 100: Fundamental Principles of Public-Sector Auditing.&lt;/em&gt; International Organisation of Supreme Audit Institutions. Available at: &lt;a href="https://www.issai.org/wp-content/uploads/2019/08/ISSAI-100-Fundamental-Principles-of-Public-Sector-Auditing-1.pdf" rel="noopener noreferrer"&gt;https://www.issai.org/wp-content/uploads/2019/08/ISSAI-100-Fundamental-Principles-of-Public-Sector-Auditing-1.pdf&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

&lt;p&gt;National Archives and Records Administration, n.d. &lt;em&gt;36 CFR 1236.20: What are appropriate recordkeeping systems for electronic records?&lt;/em&gt; Electronic Code of Federal Regulations. Available at: &lt;a href="https://www.ecfr.gov/current/title-36/chapter-XII/subchapter-B/part-1236/subpart-C/section-1236.20" rel="noopener noreferrer"&gt;https://www.ecfr.gov/current/title-36/chapter-XII/subchapter-B/part-1236/subpart-C/section-1236.20&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

&lt;p&gt;National Audit Office, 2025. &lt;em&gt;Government cyber resilience.&lt;/em&gt; HC 546, Session 2024-25. National Audit Office. Available at: &lt;a href="https://www.nao.org.uk/reports/government-cyber-resilience/" rel="noopener noreferrer"&gt;https://www.nao.org.uk/reports/government-cyber-resilience/&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

&lt;p&gt;The National Archives, 2017. &lt;em&gt;Migrating information between records management systems.&lt;/em&gt; The National Archives. Available at: &lt;a href="https://cdn.nationalarchives.gov.uk/documents/information-management/edrms.pdf" rel="noopener noreferrer"&gt;https://cdn.nationalarchives.gov.uk/documents/information-management/edrms.pdf&lt;/a&gt; [Accessed 1 October 2026].&lt;/p&gt;

</description>
      <category>government</category>
      <category>governance</category>
      <category>compliance</category>
      <category>data</category>
    </item>
    <item>
      <title>API Governance Done Properly</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Wed, 30 Sep 2026 12:44:04 +0000</pubDate>
      <link>https://dev.to/sakurasky/api-governance-done-properly-5fcl</link>
      <guid>https://dev.to/sakurasky/api-governance-done-properly-5fcl</guid>
      <description>&lt;p&gt;Well after the platform had launched, the privacy officer sent the API platform team three questions. Which APIs are live? Who calls them? What personal data do they return? The team expected to answer from a dashboard. It turned into an investigation.&lt;/p&gt;

&lt;p&gt;By the usual measures the programme had gone well. It opened an agency's services to other agencies and approved partners, teams shipped quickly, and partners integrated faster than planned. It also had a standards document, and most teams followed most of it. What it lacked was any way to see the estate it had built, so answering three plain questions meant going back through gateway logs, network scans, and interviews with engineers who had since moved on. We've merged several programmes into one here and changed the details that would identify them. Nothing below is invented.&lt;/p&gt;

&lt;p&gt;Governance had become a project, with its own budget and deadline. In the programmes that avoid this, it's something the platform does every day without being asked. It matters in government because every credential an agency issues is a statement about who it trusts with citizens' data. A credential is a trust decision made in code, which is the argument of &lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-6/" rel="noopener noreferrer"&gt;Trust Is an Engineering Output&lt;/a&gt;, and a programme opening public sector APIs to partners makes hundreds of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first audit
&lt;/h2&gt;

&lt;p&gt;The clearest way to see what went wrong is to follow one API: the eligibility check, the programme's busiest service. The audit found two versions of it running. The newer one filtered out fields partners didn't need. The older one didn't, and one partner was still calling it. Three clients shared a single API key, so the gateway could show that the API was busy but not who was busy. A test copy, loaded with production data so a partner could try things out, was still reachable months later. And the API's OpenAPI document described the service as it was at launch, which no longer matched what was deployed.&lt;/p&gt;

&lt;p&gt;Elsewhere in the estate, two teams had put APIs straight behind a load balancer to meet a deadline, so the gateway didn't know they existed. The OWASP API Security Top 10 calls this improper inventory management, one of the ten most critical API risks, and its list of warning signs reads like the audit report: hosts with no clear purpose, no record of which version is running or who should reach it, stale documentation, and no retirement plan. Its first example is a beta host running production's API without production's protection. OWASP also advises against production data in non-production deployments, and says that where it can't be avoided, those deployments need the same protection as production (OWASP, 2023).&lt;/p&gt;

&lt;p&gt;The test copy was the one finding nobody tried to defend. The privacy impact assessment had been done properly at launch, but it described the design, and by the time of the audit the design and the estate were two different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What governance after the fact buys you
&lt;/h2&gt;

&lt;p&gt;Here is how the eligibility API got to that state, one sensible step at a time.&lt;/p&gt;

&lt;p&gt;It was built in a sprint to meet a partner's go-live date, and the OpenAPI document was written afterwards for the developer portal. The design review board saw it early and approved it. Later the partner asked for three more fields, which were added in a minor release that never went back to the board. Someone added a row to the API register spreadsheet at go-live, and nobody updated it again. The partner received an API key by email and, a few weeks on, shared it with two contractors working on a joint service. After a privacy concern, the team shipped a version 2 with proper field filtering. Version 1 stayed up because nobody could say who was still calling it, and turning it off might break a service they didn't know about.&lt;/p&gt;

&lt;p&gt;Each of those steps passed the governance the programme had: the board, the register, the standards document, and the plan for a periodic audit. That model buys real things. It gives security and privacy teams a place to ask questions, and the board brings judgement no tool can supply: whether a new data flow is justified, whether a field is needed, whether an existing API could be reused instead. What it can't do is keep up. The register drifts from the day it's finished. A board that meets on a fixed schedule becomes a queue, and teams under deadline find ways around queues. The standards document describes the rules but has no way to stop an API that breaks them, and a periodic audit reports on a single day.&lt;/p&gt;

&lt;p&gt;Keep the board, and take routine conformance off its desk, so it only sees the decisions that need people, such as a new category of personal data or a new external consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What governance built in looks like
&lt;/h2&gt;

&lt;p&gt;Now run the same API through a platform that governs as it goes.&lt;/p&gt;

&lt;p&gt;The first draft of the eligibility API arrives as an OpenAPI document, before any code. The UK's API technical and data standards recommend exactly this: make the OpenAPI document the first output of design, lint it for rule and security problems, and generate the reference documentation from it (Government Digital Service and Central Digital and Data Office [GDS and CDDO], 2026). The specification is a standard, language-agnostic description of an HTTP API that both people and tools can read (OpenAPI Initiative, n.d.). The draft includes a date-of-birth field, and the pipeline flags it because the field carries no data classification. OpenAPI has no built-in marker for personal data, so agencies add their own extension or link fields to a data catalogue. The privacy officer sees the question in a pull request before the API exists, instead of in an audit after partners depend on it.&lt;/p&gt;

&lt;p&gt;A few months in, the partner asks for three more fields. The pull request fails the linter because none of them is classified, and the privacy officer approves two of the three in the review thread. Nothing reaches production without passing the same checks, and the deployed service is compared against its specification on every release, so the document the portal shows is the one that's running.&lt;/p&gt;

&lt;p&gt;Publishing goes through one door. The gateway is the only way to reach an API from outside its network, and network policy enforces that, so the catalogue of externally reachable APIs stays complete for as long as the policy holds. Regular discovery scans of cloud accounts, DNS, and certificates check that it does, and they're what would have found the two APIs behind the load balancer. The partner's sandbox is generated from the specification and seeded with synthetic data. The UK standards say test services should never hold real personal or transactional data and should still require authentication (GDS and CDDO, 2026), so the exposed test copy from the first story never gets built. GSA's own API standards, which apply to GSA rather than the whole US government, require something similar for its public APIs: listing in its API directory, versioning, an OpenAPI file, and a shared management service that handles keys, rate limits, and usage analytics (General Services Administration, n.d.). That model runs on API keys rather than OAuth, which suits open data better than personal data, but the idea of one managed route with usage analytics carries over.&lt;/p&gt;

&lt;p&gt;The contractors arrive next. Each is onboarded as its own consumer under the data-sharing agreement, with a client whose credential is bound to a key or certificate it holds, so a copied secret is useless and any one of them can be cut off without touching the others. The UK standards recommend OAuth 2.0 for API access control, with client credentials when a service calls on its own behalf, token exchange when it calls on behalf of a user, scopes checked on every request, and API keys avoided where possible. They also require agencies to log when personal or sensitive data is provided and to whom (GDS and CDDO, 2026).&lt;/p&gt;

&lt;p&gt;Then version 2 ships. Telemetry shows which clients still call version 1, and GSA's standards describe using that kind of analytics data to notify recent users before a breaking change (General Services Administration, n.d.). The guidance pulls two ways here, because GSA asks teams to keep at least one previous major version running, while OWASP points out that every live version is attack surface and needs a retirement plan (OWASP, 2023). A workable rule is that an old version stays only while it has callers, and only with the same protection as the new one. The last partner still on version 1 moves across a few weeks after being told, and version 1 is switched off.&lt;/p&gt;

&lt;p&gt;Sakura designed something close to this with SkillsFuture Singapore in 2020 (&lt;a href="https://www.sakurasky.com/case-studies/ssg-api-governance/" rel="noopener noreferrer"&gt;how SkillsFuture Singapore gated its API lifecycle&lt;/a&gt;). Each API moved through four stages (Inception, Build, Adoption, and Close), and each stage had entry and exit criteria. REST, OpenAPI, and JSON Schema were mandatory, the OWASP REST Security Cheat Sheet was enforced at the endpoint level, and nothing was published until it had passed a conformance test against the framework. The framework treated the teams building APIs and the teams consuming them as separate groups with different cadences and different controls. Standards were set centrally and day-to-day decisions stayed with the teams, which was intended to keep the gates from turning into a queue. It also kept a periodic check in place, a quarterly maturity assessment using Apigee Compass, because a platform that enforces the rules still benefits from someone asking whether they're the right ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The platform architecture that supports it
&lt;/h2&gt;

&lt;p&gt;Underneath that walk-through, API platform architecture comes down to one component per stage of the API lifecycle, each leaving evidence behind as a side effect of doing its job.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Design&lt;/td&gt;
&lt;td&gt;Specification repository and linter&lt;/td&gt;
&lt;td&gt;The OpenAPI document, field classifications, and lint results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;CI pipeline with conformance and security tests&lt;/td&gt;
&lt;td&gt;Test results tied to each version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish&lt;/td&gt;
&lt;td&gt;Gateway, developer portal, sandbox, and network policy&lt;/td&gt;
&lt;td&gt;A catalogue entry generated from the specification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consume&lt;/td&gt;
&lt;td&gt;Identity provider, gateway scope checks, and authorisation in the API&lt;/td&gt;
&lt;td&gt;Per-client credentials and a log of personal data provided&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change and retire&lt;/td&gt;
&lt;td&gt;Traffic telemetry&lt;/td&gt;
&lt;td&gt;Callers per version, per client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Around all of it&lt;/td&gt;
&lt;td&gt;Discovery scans and drift checks&lt;/td&gt;
&lt;td&gt;Anything reachable that the platform doesn't know about&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gateway's scope check only decides which operations a partner can call. The API itself still has to decide which records and which fields that partner may see. Together they're the technical side of a data-sharing agreement, and API security depends on both, though neither covers the agreement's terms on purpose, retention, and onward sharing.&lt;/p&gt;

&lt;p&gt;API observability should also record every call by API, version, client, and response class, along with a pseudonymous reference to the record returned, without logging the payload itself. The privacy officer's first two questions then become a query, and the log can still show which client received a given person's record. The third question becomes a query too, once fields are classified in the specification and responses are validated against it.&lt;/p&gt;

&lt;p&gt;Most agencies already own some of these parts. The UK standards note that access control, audit and logging, and network management are services an API management platform provides that rarely make sense to build yourself (GDS and CDDO, 2026). NIST's guidance on API protection sorts its controls into pre-runtime and runtime stages, and its March 2026 update adds an appendix listing recommended controls by API lifecycle stage, a useful cross-check for the table above (Chandramouli and Butcher, 2026). The harder work is the wiring between components, so each one feeds the next. Our &lt;a href="https://www.sakurasky.com/cloud/" rel="noopener noreferrer"&gt;Cloud practice&lt;/a&gt; builds that wiring on whichever clouds the agency already runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What belongs in the tender
&lt;/h2&gt;

&lt;p&gt;Few agencies build every row of that table themselves. The gateway might be a managed service, identity a SaaS tenant, and telemetry a monitoring vendor's pipeline. The platform trusts all of them, and every one is run by an operator that answers to the law of one or more jurisdictions. The first post in this series followed an agency whose sovereign platform leaked through the services around it, and a directory synchronised to a global tenant was the largest single cause (&lt;a href="https://www.sakurasky.com/blog/government-engineering-part-1/" rel="noopener noreferrer"&gt;Sovereign Cloud Is an Architecture, Not a Procurement&lt;/a&gt;). An API platform leans on identity even harder. The gateway's scope checks and the API's own record and field decisions both trust whatever the authorisation server issues. Anyone who operates that server, holds its signing keys, or can compel either of them can create access that the rest of the table will honour.&lt;/p&gt;

&lt;p&gt;Most of the tenders we see still leave that surrounding estate to engineering after signature. Key custody is the exception, and it shows up in requirements more often now, usually as customer-held or externally managed keys. Identity and telemetry are the biggest gaps. It's rare for a tender to ask who operates the identity provider and under which law, or where logs and traces go once they leave the platform. The answers then arrive one integration at a time, after the price is fixed.&lt;/p&gt;

&lt;p&gt;What works is a dependency inventory submitted with the tender. Each bidder lists every external service the platform needs in order to operate, with its operator and jurisdiction. That covers the identity provider and who holds its token-signing keys, where logs and traces are sent, the support access paths for the supplier and any subcontractors, the CI/CD tooling, and any managed gateway or developer portal. The agency adds what it supplies itself, such as an existing directory the platform will federate to, because no bidder's list will show that. Since the requirement is in the tender documents, every bidder answers the same question, and the inventory can be scored, set as pass or fail, or refined in a negotiation round where the procedure allows one. At award it becomes a contract schedule. Adding a service then needs notice and the agency's approval, much as GDPR already requires a processor to get written authorisation before engaging a sub-processor (European Parliament and Council, 2016, Article 28(2)). From there, egress monitoring checks the running estate against the schedule, so a new dependency shows up as drift instead of as an audit finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust the platform produces
&lt;/h2&gt;

&lt;p&gt;Send the privacy officer's three questions to an agency running a government API platform built this way, and the answers come from data it already holds. Partners can rely on published contracts, managed versions, and notice before a breaking change. Auditors get evidence the platform produced while running, not a reconstruction. Delivery teams building government digital services find the governed path is also the quickest one, so they have fewer reasons to route around it.&lt;/p&gt;

&lt;p&gt;Someone has to run the platform, and its gates have to be quick, or they become the review-board queue again in automated form. An agency with hundreds of existing APIs can't move them all at once. It can require the platform for every new API, then bring the existing estate across in order of risk, starting with APIs that return personal data or serve the most external consumers, and using gateway traffic plus a discovery scan to decide the order. Contracts for APIs that a supplier builds or hosts for the agency can require the same platform route, with the same specification and conformance checks as everything else.&lt;/p&gt;

&lt;p&gt;The privacy officer will ask the same three questions again, and next time the answer should take a query, not an investigation. Keeping access control and security testing current as partners and threats change is the part our &lt;a href="https://www.sakurasky.com/security/" rel="noopener noreferrer"&gt;Security practice&lt;/a&gt; does with agency teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;Chandramouli, R. and Butcher, Z., 2026. &lt;em&gt;Guidelines for API Protection for Cloud-Native Systems.&lt;/em&gt; NIST Special Publication 800-228, June 2025, includes updates as of 13 March 2026. National Institute of Standards and Technology. Available at: &lt;a href="https://csrc.nist.gov/pubs/sp/800/228/upd1/final" rel="noopener noreferrer"&gt;https://csrc.nist.gov/pubs/sp/800/228/upd1/final&lt;/a&gt; [Accessed 30 September 2026].&lt;/p&gt;

&lt;p&gt;European Parliament and Council, 2016. &lt;em&gt;Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation).&lt;/em&gt; Official Journal of the European Union, L 119, 4 May, pp. 1-88. Available at: &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2016/679/oj&lt;/a&gt; [Accessed 30 September 2026].&lt;/p&gt;

&lt;p&gt;General Services Administration, n.d. &lt;em&gt;GSA API Standards.&lt;/em&gt; US General Services Administration. Available at: &lt;a href="https://github.com/GSA/api-standards" rel="noopener noreferrer"&gt;https://github.com/GSA/api-standards&lt;/a&gt; [Accessed 30 September 2026].&lt;/p&gt;

&lt;p&gt;Government Digital Service and Central Digital and Data Office (GDS and CDDO), 2026. &lt;em&gt;API technical and data standards.&lt;/em&gt; GOV.UK, last updated 30 September 2026. Available at: &lt;a href="https://www.gov.uk/guidance/gds-api-technical-and-data-standards" rel="noopener noreferrer"&gt;https://www.gov.uk/guidance/gds-api-technical-and-data-standards&lt;/a&gt; [Accessed 30 September 2026].&lt;/p&gt;

&lt;p&gt;OpenAPI Initiative, n.d. &lt;em&gt;OpenAPI Specification v3.2.1.&lt;/em&gt; The Linux Foundation. Available at: &lt;a href="https://spec.openapis.org/oas/latest.html" rel="noopener noreferrer"&gt;https://spec.openapis.org/oas/latest.html&lt;/a&gt; [Accessed 30 September 2026].&lt;/p&gt;

&lt;p&gt;OWASP, 2023. &lt;em&gt;API9:2023 Improper Inventory Management.&lt;/em&gt; OWASP API Security Top 10. Available at: &lt;a href="https://owasp.org/API-Security/editions/2023/en/0xa9-improper-inventory-management/" rel="noopener noreferrer"&gt;https://owasp.org/API-Security/editions/2023/en/0xa9-improper-inventory-management/&lt;/a&gt; [Accessed 30 September 2026].&lt;/p&gt;

</description>
      <category>government</category>
      <category>publicsector</category>
      <category>apigovernance</category>
      <category>apiplatform</category>
    </item>
    <item>
      <title>Sovereign Cloud Is an Architecture, Not a Procurement</title>
      <dc:creator>Andrew Stevens</dc:creator>
      <pubDate>Tue, 29 Sep 2026 09:22:01 +0000</pubDate>
      <link>https://dev.to/sakurasky/sovereign-cloud-is-an-architecture-not-a-procurement-44a</link>
      <guid>https://dev.to/sakurasky/sovereign-cloud-is-an-architecture-not-a-procurement-44a</guid>
      <description>&lt;p&gt;The agency in this story is a composite, drawn from several engagements, though every detail in it is one we've seen. It signed a sovereign cloud contract after a careful procurement. The platform was certified, the data would stay in-country, and the provider's operating entity met the ownership conditions the policy asked for. Well into the second year, a team mapping dependencies for an unrelated migration counted thirty-two services in the agency's stack that still reached outside the sovereign boundary. The identity directory synchronised to a global tenant. Logs and traces went to a monitoring service hosted overseas, and that vendor's support staff, in another jurisdiction, could see them. A CI/CD service and a handful of developer tools each had their own route out. Separately, the platform's key management service was still running on the provider's defaults, with the settings that would have given the agency its own key custody left for a later phase.&lt;/p&gt;

&lt;p&gt;The contract covered the platform well and said very little about everything around it. Procurement had bought what the policy described, a sovereign platform, and the engineering team inherited it the way engineering teams usually inherit contracts: after signature, with a delivery date attached. In our experience that's where sovereignty tends to leak, in the systems that surround the platform: who holds the keys, where people authenticate, what the data layer will accept, where telemetry goes, and what the contract lets engineers actually check. Our earlier series, The Engineering Underneath, made the cross-industry case that sovereignty gets settled in the architecture as much as at the procurement desk (see &lt;a href="https://www.sakurasky.com/blog/engineering-underneath-part-1/" rel="noopener noreferrer"&gt;Sovereignty Versus Efficiency&lt;/a&gt;). Government cloud architecture is where that argument has the most at stake, and where the frameworks are explicit enough to show the gap clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thirty-two services
&lt;/h2&gt;

&lt;p&gt;None of the thirty-two arrived as a sovereignty decision. The identity directory had been set up years earlier for email and collaboration, and the new platform simply federated to it. The monitoring service was already the agency's standard, so new workloads were pointed at it on day one. Endpoint agents, the CI/CD service, and the developer tools were each approved separately, by different teams, for good reasons. The platform's certification didn't catch them either, and it wasn't asked to. The agency's own system authorisations had each looked at one workload at a time, so nobody had assessed the estate as a whole.&lt;/p&gt;

&lt;p&gt;Each government cloud framework points at the wider picture, although they differ a lot in how much sovereignty they demand. France's SecNumCloud qualification is aimed at protecting sensitive data from both cyber threats and extraterritorial law, and ANSSI notes that the qualification says nothing about the security of the customer services built on a qualified offer (ANSSI, n.d.). A qualified platform, in other words, doesn't make what runs on it sovereign. Australia's Hosting Certification Framework separates Assured certification from Strategic certification, the highest level, which is only available to providers that let government specify ownership and control conditions. It covers sensitive government data, whole-of-government systems, and systems rated PROTECTED (Department of Home Affairs, n.d.a), and it's currently being reformed, with new certification registrations paused since November 2025 (Department of Home Affairs, n.d.b). The UK takes a risk-based line. The National Cyber Security Centre's cloud guidance asks organisations to know where their data is and who can access it, including derivatives such as verbose logs, and it points out that data held in a local region may still be reached through an authentication service replicated worldwide (National Cyber Security Centre, 2018).&lt;/p&gt;

&lt;p&gt;These regimes ask for very different amounts of sovereignty, and this post isn't arguing for more of it across all public sector cloud use. Most OFFICIAL workloads don't need a hard sovereign boundary, and building one for them is expensive. The argument is about where a commitment leaks once an agency has decided it needs one, whatever its jurisdiction and whichever provider it uses. For those workloads, a government cloud strategy built around the platform alone leaves most of the outcome to chance. Five decisions do most of the work, and in many agencies they sit with different owners, so nobody holds the whole picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Where the keys live
&lt;/h3&gt;

&lt;p&gt;The first decision is who can decrypt the data. Encryption at rest is standard on every major platform. What varies is custody. If the provider generates and holds the keys, protection rests on the provider's controls and on the laws the provider answers to. If the agency holds the keys in its own hardware security modules, outside the provider's reach, a demand made of the provider largely yields ciphertext for data at rest, provided the provider's services can't be compelled to request key unwraps on the agency's behalf. Data in use is a separate problem, because anything the provider's services process in plaintext is still exposed, and that's where confidential computing and service design come in. Most arrangements sit in between, with customer-managed keys stored in the provider's key service, and the details of that middle ground matter.&lt;/p&gt;

&lt;p&gt;For a lot of workloads, the provider's key service is the right answer, and the NCSC accepts a provider's cloud-hosted key management service as a way to protect keys through their lifecycle (National Cyber Security Centre, 2018). The higher the classification, the more the custody question matters, because sovereign data residency and actual control can part ways: data can sit in the right country on a certified platform and still be readable by whoever can reach the keys. Holding keys independently has real costs, including an operational burden, availability planning for the key service itself, and some platform features that stop working when the provider can't see plaintext. Those costs belong in the decision, made on purpose, rather than deferred to a phase that doesn't get funded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the identity provider lives
&lt;/h3&gt;

&lt;p&gt;Next comes where people and workloads authenticate. Whoever controls authentication controls access to everything the keys protect, and an agency can keep every byte in-country while routing every login, token, and administrative session through a service that lives elsewhere. That's the case the NCSC describes, and in the composite agency the directory synchronised to a global tenant was the largest single reason the boundary didn't hold.&lt;/p&gt;

&lt;p&gt;A sovereign identity layer means the directory, the token service, and the privileged access paths for administrators sit inside the boundary, or are federated so the authoritative decision is made inside it. It also means support access gets engineered: who can open a privileged session, from where, with whose approval, and with what record left behind. Provider support access is sometimes necessary, and it should be rare, time-bound, approved by the agency, and logged somewhere the agency controls. Public sector security teams already hold their own administrators to that standard, and it has to reach the provider's people too. This is also the most expensive of the five to change. When the directory in question is the one that runs email and collaboration for the whole agency, moving it inside the boundary can be a multi-year programme, and for many workloads a well-controlled federation is the realistic answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the data layer actually accepts
&lt;/h3&gt;

&lt;p&gt;The third decision is whether the data layer enforces the rules on government data sovereignty or only documents them. In our experience most agencies classify their data carefully, and far fewer have a data layer that refuses to put a record where its classification says it can't go. Without enforcement, a backup policy replicates to a second region for resilience, an analytics team spins up a workspace in a default location, or a managed service creates a cache somewhere convenient. Each is a reasonable default, set by someone doing their job.&lt;/p&gt;

&lt;p&gt;Enforcement means classification travels with the data as metadata, and placement rules are written as policy that the platform evaluates when resources are created and when data moves. A storage account in a region that isn't permitted fails to deploy, and a replication rule that crosses the boundary is rejected with a clear reason. Derived data counts too: backups, snapshots, replicas, extracts, and the features built from them for analytics. In a public sector multi-cloud estate this gets harder, because each provider expresses these controls differently, and the agency needs one policy that means the same thing everywhere it runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the telemetry goes
&lt;/h3&gt;

&lt;p&gt;Telemetry catches a lot of teams. Logs, traces, metrics, and crash reports are data about the system, and they very often carry data from it: identifiers, request payloads, query parameters, and error messages that quote the record they failed on. The NCSC lists credentials, configuration data, derived metadata, and logs among the data types most often overlooked (National Cyber Security Centre, 2018). In the composite agency, the overseas monitoring service was receiving a steady stream of exactly that, and the vendor's support staff could read it.&lt;/p&gt;

&lt;p&gt;Keep telemetry inside the boundary, or scrub it before it leaves, so observability doesn't become the easiest way out. Then watch what leaves at all. An agency can only show where its data goes for the traffic it can see, and egress monitoring that records every connection leaving the boundary, by service and destination, would have surfaced most of the thirty-two on day one rather than well into the second year. It won't show key custody or how federation is configured, which is why it sits alongside the other decisions, and it's also what catches the next developer tool that ships with an undeclared path home.&lt;/p&gt;

&lt;h3&gt;
  
  
  The contract the engineering team can hold
&lt;/h3&gt;

&lt;p&gt;The fifth decision is to write the sovereign commitment in terms engineers can test. Procurement teams do careful work on ownership, jurisdiction, liability, and exit. What often goes unwritten are the technical conditions that make those terms real, and they fall into two groups. Some belong in the contract, because the provider has to support and evidence them: where keys can be held, which regions data may occupy, how provider support access is approved and recorded, and what an exit looks like in practice. Others are the agency's own architecture decisions, such as where identity is resolved and where telemetry may go, and they belong in a design record kept alongside the contract, so the engineering team is held to them as firmly as the provider is held to its side. Exit deserves a sentence of its own, because an agency that can't move a workload off a provider in a stated time, or keep it running if the provider's global control plane is cut off, has less control than its contract suggests.&lt;/p&gt;

&lt;p&gt;The broader shift in government assurance points the same way. FedRAMP 20x, the US programme's overhaul of cloud security assessment and certification, rests on principles that treat a provider's security as something to be enforced and reported continuously, and that give ongoing evidence more weight than written policy (FedRAMP, 2026). Those principles are aimed at providers, and they work just as well for the conditions an agency writes down for itself. Singapore's Government on Commercial Cloud is a platform rather than a certification regime, and its emphasis is assurance and speed more than sovereignty: it gives agencies a standardised way onto commercial cloud and builds continuous compliance, observability, and auditability into the platform. GovTech reports that more than seventy percent of eligible government systems now run on it (GovTech Singapore, 2025).&lt;/p&gt;

&lt;p&gt;Sakura saw a small version of the same idea with SkillsFuture Singapore, where controls were engineered into the platform and every API was tested for conformance before release (&lt;a href="https://www.sakurasky.com/case-studies/ssg-api-governance/" rel="noopener noreferrer"&gt;SkillsFuture Singapore case study&lt;/a&gt;). Building the enforced, provider-neutral foundation that makes conditions like these testable is a large part of what Sakura's &lt;a href="https://www.sakurasky.com/cloud/" rel="noopener noreferrer"&gt;Cloud practice&lt;/a&gt; does for public sector clients.&lt;/p&gt;

&lt;p&gt;The practical step is for procurement and engineering to sit down together before the tender or call-off goes out, so that sovereign cloud procurement specifies the architecture around the platform as well as the platform itself, and the engineering team inherits a commitment it can check.&lt;/p&gt;

&lt;p&gt;Turning those conditions into controls an agency can evidence on demand, and keeping them true as the estate changes, is work Sakura's &lt;a href="https://www.sakurasky.com/grc/" rel="noopener noreferrer"&gt;GRC service&lt;/a&gt; does alongside agency engineering and procurement teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;ANSSI, n.d. &lt;em&gt;Cloud: posture générale et actions de l'ANSSI sur le cloud (SecNumCloud).&lt;/em&gt; Agence nationale de la sécurité des systèmes d'information. Available at: &lt;a href="https://cyber.gouv.fr/enjeux-technologiques/cloud/" rel="noopener noreferrer"&gt;https://cyber.gouv.fr/enjeux-technologiques/cloud/&lt;/a&gt; [Accessed 29 September 2026].&lt;/p&gt;

&lt;p&gt;Department of Home Affairs, n.d.a. &lt;em&gt;Hosting Certification Framework: framework overview.&lt;/em&gt; Australian Government. Available at: &lt;a href="https://www.hostingcertification.gov.au/framework" rel="noopener noreferrer"&gt;https://www.hostingcertification.gov.au/framework&lt;/a&gt; [Accessed 29 September 2026].&lt;/p&gt;

&lt;p&gt;Department of Home Affairs, n.d.b. &lt;em&gt;Hosting Certification Framework: home.&lt;/em&gt; Australian Government. Available at: &lt;a href="https://www.hostingcertification.gov.au/" rel="noopener noreferrer"&gt;https://www.hostingcertification.gov.au/&lt;/a&gt; [Accessed 29 September 2026].&lt;/p&gt;

&lt;p&gt;FedRAMP, 2026. &lt;em&gt;FedRAMP 20x.&lt;/em&gt; US General Services Administration. Available at: &lt;a href="https://www.fedramp.gov/20x" rel="noopener noreferrer"&gt;https://www.fedramp.gov/20x&lt;/a&gt; [Accessed 29 September 2026].&lt;/p&gt;

&lt;p&gt;GovTech Singapore, 2025. &lt;em&gt;Government on Commercial Cloud (GCC).&lt;/em&gt; Government Technology Agency of Singapore, last updated 26 September 2025. Available at: &lt;a href="https://www.tech.gov.sg/products-and-services/for-government-agencies/software-development/government-on-commercial-cloud/" rel="noopener noreferrer"&gt;https://www.tech.gov.sg/products-and-services/for-government-agencies/software-development/government-on-commercial-cloud/&lt;/a&gt; [Accessed 29 September 2026].&lt;/p&gt;

&lt;p&gt;National Cyber Security Centre, 2018. &lt;em&gt;Cloud security guidance, Principle 2: asset protection and resilience.&lt;/em&gt; Version 2.1, reviewed 7 June 2023. Available at: &lt;a href="https://www.ncsc.gov.uk/collection/cloud/the-cloud-security-principles/principle-2-asset-protection-and-resilience" rel="noopener noreferrer"&gt;https://www.ncsc.gov.uk/collection/cloud/the-cloud-security-principles/principle-2-asset-protection-and-resilience&lt;/a&gt; [Accessed 29 September 2026].&lt;/p&gt;

</description>
      <category>government</category>
      <category>publicsector</category>
      <category>sovereigncloud</category>
      <category>cloudarchitecture</category>
    </item>
  </channel>
</rss>
