Skip to content

16 min read

Ownership, change rate and trust boundary

On this page

The decision record a proposal eventually has to survive contains five fields — client reuse, ownership, change rate, security boundary, and the alternatives you rejected. Reuse is settled by the deploy-boundary test, and rejected alternatives is a discipline rather than a measurement. That leaves three facts about the capability itself.

Reading them separately is the whole skill, because they disagree, and a record in which every listed reason happens to favour whatever the author already wanted is a record a reviewer will correctly distrust. Each of the three has a different unit: ownership is about who is permitted to change behaviour, change rate is about how often the interface has to be renegotiated, and the trust boundary is about what the caller is able to reach. Only two of the three are things you can trade against cost.

A capability another team owns already has a boundary

Consider a capability that answers "what is this customer entitled to?", where the entitlement rules belong to a Billing team, live in Billing's repository, and are changed under a compliance sign-off. It has one consumer. It should still be behind a boundary, and the reason has nothing to do with how many agents want the data and everything to do with who is allowed to change the answer.

Billing owns the rules, Billing gets audited on them, and a customer's plan downgrade has to take effect on the next call rather than on your next deploy. If the agent team vendored a copy of that logic, they would be shipping Billing's decisions on the agent team's schedule, and the first time a rule changed on a Tuesday and the agent kept quoting last month's entitlements until Thursday, the incident would land on the agent team's pager for a defect they did not write.

The organisational boundary exists whether or not anyone expresses it in code. Ownership means somebody else decides what correct means, reviews changes to it, and answers for it when it is wrong. Expressing that boundary as an interface makes the ownership enforceable: their logic runs in their process, under their tests, deployed by them, and your side holds a call rather than a copy. Leaving it unexpressed does not remove the boundary; it moves the coordination into a channel where somebody has to remember to tell you.

This is the one axis where client count genuinely does not matter, and the reason is worth stating plainly. Client reuse is an argument about amortising a fixed cost across callers, so it scales with the number of callers. Ownership is an argument about who is permitted to change behaviour, which is fully present at one caller. A single consumer of somebody else's governed logic has the same coupling problem as forty of them.

Two arguments measured in different units cannot be netted against each other, and the consequence of that is practical rather than philosophical. A reuse argument answers how much of a fixed cost each caller carries, so its answer moves whenever the caller count moves. An ownership argument answers who is permitted to decide what correct means, and that answer is identical at one caller and at forty, because permission is not divided among the people who exercise it. A record that files them in the same column and counts them as two votes for a boundary collapses the moment a reviewer removes the reuse vote — even though nothing about the ownership fact has changed, and the ownership fact was on its own sufficient. Keeping the two apart is what lets an entitlements decision survive the observation that it has exactly one consumer.

What ownership does not tell you is that the interface should be MCP. If Billing already exposes entitlements as an internal HTTP API with an OpenAPI description consumed by six services, the actual question is whether an MCP server should exist on top of it and who should own that. A wrapper is small:

python
# entitlements_mcp.py — owned by Billing, deployed on Billing's train
from mcp.server.fastmcp import FastMCP
from billing_client import EntitlementsClient   # Billing's own client

mcp = FastMCP("entitlements")
client = EntitlementsClient(base_url=settings.ENTITLEMENTS_URL)

@mcp.tool()
def customer_entitlements(customer_id: str) -> dict:
    """Current plan, seat count, feature flags and overage state for a
    customer. Reflects changes immediately; do not cache across turns."""
    e = client.get(customer_id)
    return {
        "plan": e.plan_code,
        "seats": e.seats,
        "features": sorted(e.enabled_features),
        "overage_usd": round(e.overage_cents / 100, 2),
        "as_of": e.as_of.isoformat(),
    }

Thirty lines of adapter is a rounding error next to the six weeks of building an entitlements service that already exists, and the interesting question is the first comment in that file. An adapter owned by the agent team is a copy of Billing's contract living outside Billing's tests, which recreates the staleness problem one layer up. An adapter owned by Billing means the tool description, the argument names and the field semantics are maintained by the people who change the underlying rules. Ownership of the capability should carry ownership of its agent-facing description, because the description is the part the model reads, and a tool description that lies is a defect in the capability rather than in the agent.

Change rate pushes against the shared server

Fast change is the axis most often quoted in favour of a server, on the reasoning that a central service can be updated once instead of everywhere. That reasoning holds for the implementation and inverts for the interface. Inside one release train, renaming a field is a single commit that changes the producer and every caller together, reviewed as one diff and rolled back with one lever. Across a deploy boundary, the same rename becomes a contract negotiation with every consumer on their own schedule, and the schedule you have to wait for is the slowest one.

The inversion is worth walking through, because "update it in one place" is the most persuasive sentence in these proposals and it is true of the wrong half of the system. A capability has an implementation and an interface. Centralising the implementation genuinely does mean one edit instead of one per consumer, and that much of the reasoning survives. But nothing outside your process can depend on your implementation. What a consumer depends on is the shape of what comes back — field names, enum values, and what those values mean — and that set of assumptions lives in the consumer's own code, on the consumer's own train. Centralising the implementation therefore centralises nothing the consumer holds. Each non-additive change still has to be made twice in every consumer's world: once by you, and once by them. The server has not removed the per-consumer edits; it has moved them onto schedules you do not control, and replaced a single reviewable diff with a coordination problem.

State diagram: start → Proposed; Proposed → DualServing (ship old + new fields); DualServing → Notified (consumers told, dates agreed); Notified → Draining (consumers migrate at their own pace); Draining → Removable (last consumer confirmed off old field); Removable → start (remove old field); Draining → Notified (a consumer misses the date).

One support-ticket schema went through eleven changes in six months — two field renames, four added fields, three narrowed enums, and two changes to what status meant for merged tickets. Additive changes cost nothing on either side of the boundary. The seven that were not additive each needed a dual-serving window, a period during which the server returns both the old field and its replacement so that consumers can migrate without breaking, and the window was sized by the slowest consumer's release schedule — the Growth team ships monthly — which put it at six weeks. Seven windows of six weeks each, overlapping across a six-month period, means the server was inside at least one deprecation window on roughly four days out of five, and somebody was tracking which consumer was on which field the whole time.

The seven is a count rather than an impression, and confirming that is worth the moment it takes, because every claim after it rests on the number. Sort the eleven changes into the four kinds the sentence names — two renames, four additions, three narrowed enums, two shifts in what status meant — and they add back to eleven exactly, with no residual bucket for changes that fitted none of the four. That closure is what licenses the subtraction. Additions are the only kind safe for a consumer that has not redeployed, because a field a consumer does not read cannot break it; each of the other three either removes a name the consumer used, removes a value it accepted, or leaves both in place and changes what they mean. Take the four additions out of the eleven and seven remain. Add the renames, the narrowed enums and the semantic shifts together and you arrive at the same seven from the other direction. Two routes to one number with nothing left over is what turns "most of them were breaking" into a count somebody else can reproduce against their own schema by sorting its history into the same four buckets.

The coverage figure in that sentence looks impossible on a first reading and is not. Seven windows of six weeks each is forty-two weeks of window against a six-month period that holds about twenty-six, so if the windows had to be laid end to end there would not be room for them, and if they merely had to be disjoint the period would be saturated. Neither is required. A window opens when a change ships and closes when the last consumer confirms it has migrated, so windows overlap freely, and each one covers a little under a quarter of the six months wherever it happens to fall. Seven stretches of that size, spread across the period rather than clustered, leave a little under a fifth of the days inside no window at all — which is the four days in five the sentence claims. Both hedges in it are load-bearing: "at least one" is what permits the overlap, and "roughly" is what absorbs the assumption that the changes were spread out. A run of seven changes landing in the same fortnight would push the covered fraction well below that.

Change rate of the capabilityOne release trainShared server, 4 consumers
Stable, a few additive changes a yearone commit eachone announcement each, no windows
Monthly, mostly additiveone commit eachoccasional window, low tracking cost
Weekly, semantics shiftingone commit eachnear-permanent dual serving, per-consumer version tracking, migration nagging as routine work

The cost is real and it is not a veto, which is the part that matters when the axes disagree. Entitlements changes constantly and belongs behind a boundary anyway, because ownership leaves no alternative. What change rate does is set the price of the boundary and tell you what has to be in place before you build it: a versioning policy, a way to know which consumer is calling which tool version, and somebody whose job includes chasing the slow ones. A proposal that describes a weekly-changing capability and contains no versioning plan has priced the boundary at zero.

There is a second-order effect worth naming. When the interface is expensive to change, teams stop changing it, and the way they avoid changing it is to add optional fields and overload the meaning of existing ones. Six months of that produces a tool whose description is a paragraph of conditionals, which degrades the thing the model actually depends on. A capability that is still finding its shape is better off inside one train until the shape stops moving.

The credential the caller must not hold

An agent that issues refunds through a payment provider is the case where the security fact overrides everything else, and it can be correct at one consumer. The agent handles text written by customers, so its context contains input from people with a reason to try to steer it. In-process, the refund capability means the payment provider's secret key is in the agent's memory, with whatever scope that key carries, reachable by any code path in the process and constrained only by the policy function the agent's own code chooses to call. Behind a boundary, the agent holds a token that permits exactly one operation, and the constraints run on the far side, where nothing the agent does can route around them:

python
@mcp.tool()
def issue_refund(order_id: str, amount_usd: float, reason: str) -> dict:
    caller = current_caller()                     # from the verified token
    order = orders.get(order_id)
    if order.customer_id != caller.customer_scope:
        raise ToolError("order does not belong to the authenticated customer")
    if amount_usd > order.refundable_usd:
        raise ToolError(f"exceeds refundable amount {order.refundable_usd}")
    if amount_usd > POLICY_MAX_AUTOMATED_USD:     # 200.00
        return {"status": "queued_for_human", "queue": "refunds-review"}
    if refunds.count_since(order_id, hours=24) >= 1:
        raise ToolError("a refund was already issued for this order today")
    audit.write(actor=caller.id, action="refund", order=order_id,
                amount=amount_usd, reason=reason)
    return provider.refund(order_id, amount_usd)

Every one of those checks could be written inside the agent's process, and in one process they are advisory. The provider key sits next to them, so a bug, a new code path, or an instruction the model follows too literally can reach the provider without passing through the guard. Across a process boundary the guard is the only way through, the agent's token cannot be widened by the agent, and the audit line names a caller that the caller did not get to choose.

The tempting misuse of this axis is to apply it to reads. A proposal to put a ticket reader behind a server "so the agent does not touch the database directly" sounds like the refund argument and is not, because the agent already holds a database credential and already runs six other queries with it. Relocating one of those queries behind a network hop removes no access the agent has, so the security column is empty and the proposal has to win on the other axes, where a direct function already measured cheaper. The test is whether the boundary takes something away from the caller that the caller currently has. If the caller ends up with the same reach and one more hop, the boundary is decoration.

Running that test as an explicit before-and-after is what keeps it from becoming a judgement call. Write down what the caller can do today, write down what it can do once the boundary exists, and look at whether anything has left the first list. In the refund case something has: the provider key with its full scope is gone from the agent's process, so no code path in that process can reach the provider except through a token that permits one capped operation, and the checks above are on the only route there is. In the ticket case nothing leaves the list. The agent keeps the database credential, keeps the other queries it runs with it, and gains a hop. A boundary that removes nothing from the caller's reach cannot enforce anything, because enforcement means something the caller could do before and cannot do now. An empty security column for a read is therefore a correct entry rather than a gap somebody forgot to fill, and a proposal that fills it anyway has described the server's authentication instead of the caller's reach.

The claim here is narrow. A process boundary is what provides the enforcement, and MCP is the interface description sitting on top of it, so the security argument is really an argument for a separate service that an agent then needs to reach. It becomes an argument for MCP specifically when the caller is an agent client that has to discover the tool, read its schema, and be told what it is for — which is the case when your callers are not all your own code. If the only caller is a service you wrote, an internal HTTP endpoint with a scoped credential gets you the same enforcement without a protocol.

AxisWhich way it pushesThe fact that settles itWhat it costs to read it wrong
Client reusetoward a boundary as consumers growa named consumer outside your deploy boundarya service with one caller, or N copies drifting
Ownershiptoward a boundary at any client countanother team decides what correct means and is audited on ityou ship their decisions on your schedule and carry their defects
Change rateagainst a shared serverhow many non-additive interface changes a year, and the slowest consumer's cadencepermanent deprecation windows, or an interface nobody dares change
Security boundarytoward a boundary, overriding low reusea credential or scope the caller must not holda broadly scoped secret inside a process that reads untrusted input

Two of these are constraints and two are trade-offs

The ordering above is not a formula, and treating it as one produces the failure this chapter is about. Ownership and security are constraints, in the sense that violating them leaves you with something you are not allowed to have: logic you may not modify but are shipping copies of, or a credential in a process that reads text written by strangers. Reuse and change rate are trade-offs that net out against cost, and they can be paid for.

That distinction is what tells you which fact to lead with. A record that lists all four as though they were equally weighted votes will be argued with, because a reader can always find a fifth adjective and add it to the pile, and a pile of adjectives has no arithmetic in it. What survives review is a record that names which fact was decisive and what the others cost — "ownership, and change rate costs us a versioning policy and half a day a month" is a position; "ownership, reuse, standardisation and future-proofing" is a mood.

The axes also fail differently when you read them wrong, which is the last column of the table above and the reason it is there. Misreading reuse leaves you operating something with one caller. Misreading ownership leaves you carrying someone else's defects on your pager. Misreading change rate leaves you with an interface nobody dares touch. Misreading the security boundary leaves a broadly scoped secret inside a process that reads untrusted input, which is the only one of the four whose cost is not measured in engineering days.

Three facts about a capability, read separately, produce a decision that can be checked; read together as a general impression, they produce whatever the author already believed. Ownership is present at one caller and does not care about counts. Change rate is a price, not a veto, and it is the price of the interface rather than of the implementation. A credential the caller must not hold settles the question on its own, and no amount of low reuse argues it back.

None of that yet explains what belongs in the record, which is where these facts have to survive contact with a reviewer. The next chapter is about that document, and about the single most common thing written in it instead of a fact.

When MCP is the wrong answer — read the current version at https://daviddominguez.dev/learn/en/when-mcp-is-the-wrong-answer/ownership-change-rate-and-trust-boundary

Back to top