Chapter 7 of 7 · 13 min read
Constraining an unavoidable trifecta
On this page
A customer-support agent reads the customer's account, reads the customer's inbound message, and replies to the customer by email. That is three legs, and every move from the previous chapters is off the table. You cannot remove the account read, because an agent that cannot see the account cannot resolve the ticket. You cannot drop the inbound message, because it is the question. You cannot block the reply, because replying is the entire product.
This is the case the counting heuristic runs into eventually, and it is common rather than exotic: the trifecta is present because the design is useful, and every leg is load-bearing. The question becomes whether three legs is a reason not to ship at all, and if it ships, what "constrain" can mean when "remove" is gone — which is a different engineering problem with a different standard of evidence.
The strongest case for not shipping
Start by making the case against as well as it can be made, because it is not a weak one. All three legs are present and permanent, which means the exfiltration chain is complete and cannot be broken by the standard move; every mitigation available is a constraint on a live capability rather than a removed one, and a constrained leg is defined by what it still permits and gets probed at every point on that boundary. The attacker's entry — the inbound message — is not an edge case the agent might encounter but the primary input it processes on every single ticket, all day, at volume. And the private data is real customer records, so a successful chain is a genuine breach, not a rude message in a channel. A reviewer who sees a complete, permanent, high-volume trifecta over sensitive data and asks hard questions before shipping is doing the job correctly, and the burden is on the design to show the constraint holds rather than on the reviewer to prove it fails.
Where that case goes wrong is in treating "three legs" as a stop rather than as the start of the constraint conversation, because by that standard no support automation of any kind could ever ship, and the trifecta was meant as a design heuristic for finding dangerous combinations, not as a blanket prohibition on the combinations that happen to be useful. The honest position is between the two: the design cannot ship as an unconstrained trifecta, and it can ship if each leg is constrained enough that a completed chain is contained rather than catastrophic. Getting there means refusing the argument that usually gets used to wave the whole thing away.
The same-person argument, and the four places it breaks
The comforting argument is that the trifecta does not really apply here, because the untrusted content and the private data belong to the same person — the customer is reading their own record, and a customer seeing their own account is not a leak. It is a real observation and it holds for the simplest case, a single customer emailing about their own account and getting a reply only they receive. It fails the moment the situation is anything more than that, and it fails in four specific places, each of which the argument hides.
| Where it breaks | What happens | Which leg it actually is |
|---|---|---|
| Reply quoted into a shared thread | The email lands in a thread with other recipients, or a ticket others can read | The outbound leg reaches beyond the one customer |
| Impersonation | Someone emails in pretending to be the customer | The "same person" is now the attacker; private data flows to them |
| Knowledge base takes writes | The agent reads a knowledge base — the shared article store it consults for answers — that other customers or vendors can write into | A second untrusted-content source, authored by strangers |
| Account mentions a third party | The record names another customer, a related account, an internal note | The private data is not the requester's own after all |
Each row is a case where the private data and the untrusted content do not belong to the same person, which is the exact premise the argument depends on. Impersonation is the sharpest: the argument assumes the sender is the customer, and authentication of an inbound email is weak enough that assuming it is the whole vulnerability. If an attacker emails in as the customer and the agent reads the account and replies "as if to the customer," it has read private data at the direction of untrusted content and sent it to the attacker, which is the trifecta firing exactly as drawn. The knowledge-base row reintroduces the indirect untrusted input that a tool manifest never lists: the moment the agent reads a store that accepts writes from anyone outside the trust boundary, that store is an injection channel regardless of how well-authenticated the inbound email is. Using the same-person argument to dismiss the concern hides all four of these at once, which is why it is worse than no argument — it retires the question before the four real risks are looked at.
It is worth saying why the argument is tempting, because dismissing it as sloppy makes it harder to spot next time. It is a genuine security argument of a familiar and usually valid shape: if the data and the instruction come from the same principal, then an instruction that causes the data to be shown to that principal has not crossed any boundary, and there is nothing to defend. That reasoning is sound. What it needs, and never states, is that the sender of the message is the owner of the data — and that premise is not a fact about the design, it is the output of an authentication step. The argument, in other words, quietly delegates its whole weight to whatever decides identity on the inbound channel, and on a support address that decision is frequently a From header, which is a string the sender chooses. So the argument is not wrong in its logic and is unsupported in its premise, and the way to test it in a review is to ask what would have to be true for it to hold and then go and look at whether that thing is true.
What constraining a leg means here
Since no leg can be removed, constraining means reducing each leg's reach so that the chain, if it completes, moves less and moves it to fewer places. Each leg has a constraint that keeps the capability and bounds it.
The private-data leg is constrained by scoping the read to the authenticated requester and nothing adjacent. The agent should read this account, resolved from a verified identity, and not be able to read other accounts, internal notes, or the third-party records the table above warned about — enforced at the data layer as a per-request authorization scope, so that a payload telling the agent to "pull the ten most recent accounts" hits a boundary the model cannot argue past. The strength of this leg's constraint rests on the strength of the identity check on the inbound message, which is why impersonation is the risk to invest in: the read scope is only as tight as the authentication that decides whose scope it is.
The untrusted-content leg cannot be removed but can be narrowed to sources you can reason about. If the knowledge base takes writes from others, that is a second injection channel, and constraining it means making the agent's reference material read-only from trusted authors, or treating any externally-writable source as untrusted and not letting content from it drive privileged reads. The inbound message itself stays untrusted — it always will — so the constraint on the outbound leg is what actually contains a successful injection.
The outbound leg is constrained by binding the destination to the verified requester rather than to any address the model produces. The reply goes to the authenticated customer's address on file, not to an address the message body specifies, so an injected "reply to attacker@example" cannot redirect it. Quoting into shared threads is constrained by controlling what the agent includes — not echoing raw account fields into a reply that might be forwarded, bounding what a single reply can contain, so that even a redirected or forwarded message carries little.
# Constraints enforced outside the model, one per leg
def handle_ticket(inbound_email):
identity = verify_sender(inbound_email) # constrain: strong authn gates everything
if not identity.verified:
return escalate_to_human(inbound_email)
account = read_account(scope=identity.customer_id) # constrain: per-request scope, this account only
kb = read_reference(trusted_authors_only=True) # constrain: no externally-writable sources
reply = agent.draft(inbound_email, account, kb)
reply.to = identity.address_on_file # constrain: destination bound to verified requester
reply.body = redact_third_parties(reply.body) # constrain: bound what leaves in one message
return send(reply)None of these removes a leg. Each keeps the capability the product needs and reduces how far it can reach when the injection lands, which is the difference between shipping a bounded trifecta and shipping an unbounded one.
The word "bounded" can be given an exact meaning here rather than an approving one, and doing so is what lets two designs be compared. A successful exfiltration is a pairing: some quantity of data, delivered to some destination. The blast radius is therefore the size of the set of readable records multiplied by the size of the set of reachable destinations, since an attacker who can steer the agent can request any combination of the two. Take the unconstrained version first. Every account is readable, so the first set is the whole customer base; any address the model writes can receive the reply, so the second set is every address on the internet. Multiplying those gives a radius bounded by nothing in the design. Now apply the two constraints. The read scope shrinks the first set to a single account, because the authorization layer resolves the scope from a verified identity and refuses anything else. The destination binding shrinks the second set to a single address, because the reply target is read from the account record rather than from the model's output. One times one is one: the worst outcome of a fully successful injection is that account's data arriving at that account's own verified address. That product is what a decision record can state and a reviewer can disagree with, and it also shows why the two constraints have to ship together — leaving either factor unbounded leaves the product unbounded, however tight the other one is.
Believing the constraint in production
A constraint you cannot observe is a constraint you are only hoping holds, so the last requirement is the production signal that would tell you it is holding or slipping. The canary discipline becomes a standing control rather than a one-off test: plant canaries in the account store and run synthetic tickets — support requests you file yourself from an account you own, indistinguishable to the agent from real ones — carrying injection payloads against production on a schedule, and alert if a canary ever reaches an address that is not the verified requester's. That is the direct signal — data moving past the outbound constraint — and it should be silent for as long as the constraint holds and loud the instant it does not.
Around that, watch the boundaries each constraint depends on. The authentication rate — how often verify_sender fails or escalates — tells you whether the identity check the whole read scope rests on is under pressure or being bypassed. Any reply whose destination differs from the address on file is a constraint violation and should be impossible; if the count is not zero, the outbound binding has a hole. Reads that resolve to a scope other than the requester's, third-party identifiers appearing in outbound bodies after redaction, and content in the reference base written by authors outside the trusted set are each a specific one of the four failure rows made observable.
Those counters are not a list somebody assembled from experience; each one is generated by a row of the table above, and running the correspondence out loud is the check that none of the four rows was left without a control or a signal. Impersonation is answered by the verified-identity gate, and its counter is the rate at which verify_sender fails or escalates, since a rising rate is what an attacker probing the identity check looks like from the inside. A reply quoted into a shared thread is answered by binding the destination to the address on file, and its counter is the number of replies whose destination differs from that address, which the constraint guarantees to be zero and which is therefore a violation rather than a metric. A knowledge base taking writes from others is answered by restricting reference material to trusted authors, and its counter is content in the reference base written by anyone outside that set. An account record naming a third party is answered by the per-request read scope together with the redaction step, and it needs both of its counters — reads resolving to a scope other than the requester's, and third-party identifiers still present in outbound bodies after redaction — because the record can name someone else even when the scope is correct. Four rows, four constraints, and every counter traceable to the row it was derived from, with nothing on the monitoring list that no failure mode asked for.
The signal that the constraint is holding is not "no incidents," which is the absence of evidence; it is these counters sitting at the values the constraints guarantee — destination mismatches at zero, out-of-scope reads at zero, canaries never leaving to an unverified address — measured continuously, because a constraint on a live capability decays and the counters are how you catch the decay before an attacker does. That decay is not hypothetical: every one of these constraints is a narrowing of something the product needs, so each has a standing ticket waiting to be filed against it by somebody with a legitimate complaint about a reply that did not go where a customer wanted it to.
That is the shape of the whole heuristic in use. Counting legs found the danger, tracing found the legs the manifest hid, a canary and an owned endpoint proved the chain was real, boundaries removed a leg where one could be spared, the equivalent-route walk kept a block from being theatre, the four dimensions chose between blocks that looked equal, and here, where nothing can be removed, constraints bound each leg and production counters show them holding.
The trifecta is not a gate that passes or fails a design; it is a lens you keep pointed at the system as it changes, because the two-leg agent is one feature request from three and the constrained trifecta is one loosened counter from an incident. You apply it the next time someone proposes merging two safe agents, adds a documentation fetcher to something that reads secrets, or points a support bot at a knowledge base anyone can edit — which is to say, continuously, on the systems you already run.
The lethal trifecta — read the current version at https://daviddominguez.dev/learn/en/lethal-trifecta/constraining-an-unavoidable-trifecta