Build1 publisher3 min readPublished
tenant-invariant denies an agent's tool call when the server cannot resolve the resource owner
The experimental Rust crate keeps tenant_id out of the tool schema and compares an authenticated actor's tenant against ownership that the server resolved itself, allowing the call only when the two values match.
The Engineer · Build desk

What happened
- An experimental Rust crate called tenant-invariant checks tenant isolation as an executable invariant before an AI agent's proposed tool call is executed.
- A tenant identifier appearing in model output may have come from a system prompt, an earlier tool result, or text the user typed, and the author says none of those sources prove authority.
- The check compares the actor's tenant, taken from authenticated server-side context, against an owner the application resolved from its database or another trusted service.
- Same-tenant access is allowed, while cross-tenant access and unknown ownership both fail closed, and the library refuses to trust tenant IDs produced by the model.
- The crate is published on crates.io and installed with cargo add tenant-invariant, with a runnable contract_lookup example in the repository.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost Denying unknown ownership charges the guard's availability to whatever resolves owners: during a lookup outage, paying same-tenant users are refused along with the cross-tenant call the rule exists to stop.
- constraint Keeping tenant_id out of the tool schema means a forged tenant claim in model output has nothing to bind to, because the only tenant value the application reads comes from the session it authenticated.
- decision Adopters have to decide where the comparison sits and who writes the ownership resolution, since the library consumes an owner it never looks up and cannot intercept a handler that forgets to call it.
Tool calling inserts a decision-maker into an otherwise familiar request path: the user prompts, the model picks a tool, the model supplies the resource IDs, and the application executes them [7]. The convenient tool schema puts tenant_id beside contract_id in one payload, which, according to the author of tenant-invariant, gives the model a say in something it should not control [9].
The failure does not look like an attack in a log. A support agent working for a user in tenant-a produces get_contract("contract-b"): the tool exists, the argument has the right shape, and the contract belongs to tenant-b [5]. "A better prompt might make this happen less often. It cannot turn the prompt into an authorization boundary," the author wrote [6].
The rule is a split of authority. The model may propose a resource ID, and it may not tell the application who owns that resource [11]. In the crate's flow the agent proposes the ID, the server resolves the owner from a trusted source, the comparison runs, and only then does existing authorization and the tenant-scoped data operation proceed [8]. check_tenant takes the actor and the owner and returns Decision::Allow or Decision::Deny with a reason [16], and the runnable example in the repository prints blocked: CrossTenant [18].
Unknown ownership denies as well. "If the lookup fails, there is not enough information to authorize the operation," the author wrote [13]. I think that is the correct default for tenant-scoped data, and it has a price: while the ownership lookup is down or timing out, legitimate same-tenant requests are denied along with everything else [23].
An equality check is an odd thing to publish as a library, and the author says so: "The actual comparison could be an if statement. I did not want to hide that fact behind a large abstraction" [14]. The types are what remain. TenantId rejects an empty identifier, ResourceOwner::Unknown makes a failed ownership lookup visible, and Decision makes the caller deal with both the allowed and the denied path [15]. On the choice of language the author is measured, writing that representing unresolved states directly "does not make the system secure by itself, but it makes the intended control flow harder to ignore" [19].
Whether this transfers depends on two properties of your system. The tool execution path has to reach Rust, since the guard is a crate installed with cargo add tenant-invariant [17]. Your actor tenant has to arrive from authenticated server-side context, because the only thing compared is that value against the resolved owner [12]. The ownership lookup itself stays in your code: the library takes server-resolved ownership as an input and does not perform the resolution [3]. The first tests cover three cases, same-tenant allow, cross-tenant deny and unknown deny, plus scenarios for a forged tenant claim in model-generated arguments [20][22].
What to watch
- Whether the crate moves from comparing supplied values to resolving ownership itself, which would move the trust boundary inside the library.
- A documented middleware hook for a tool dispatcher, or a 1.0 release, would say whether this is meant for production paths.
- Equivalents for the languages most agent harnesses actually run in, which would decide how far the pattern travels beyond Rust.