MM3journeys

Maturity · security · 2026-10-16

Keep customers’ data apart. Our first check missed a leak. Here is how it got fixed.

One sentence makes your agent build a task service where two customers share the code and never see each other’s tasks. Then we put leaks in on purpose to see if MM3’s check would notice. The first version of the check did not.

  • 1sentence from you
  • 1planted leak the first check let through
  • 15 / 15planted leaks flagged by the third version
  • $0.00007per check, about (estimated from tokens)

Scroll. The panel follows the story.

1 · You

You type one sentence.

Open your coding agent in a small task service that has almost nothing in it. Type: add tenant isolation to this task service: two customers share it, and neither may ever see the other’s tasks.

That is the whole request. Without a recipe, an agent adds a customer column and a filter from memory, and nothing says it covered every path. One forgotten path is a data breach.

A tenant is one customer of a service that many customers share. Tenant isolation means one customer can never read or change another customer’s data, not even by mistake.

ONE LANE PER TENANTwho is askingTASK STOREwhat you calltenants kept apartno tenant reads another's rowstenant Arequesttenant contextfrom the login, never fromthe requestfiltered readevery read filters by tenantown rowseach row carries itstenanttenant Brequesttenant contextfrom the login, never fromthe requestfiltered readevery read filters by tenantown rowseach row carries itstenantcallsno tenant filterWall = what keeps one tenant’s rows from another. Each lane asks as one tenant.callsbreaks the ruleONE LANE PER TENANTwho is askingtenantskeptaparttenant Arequesttenant contextfrom the login, neverfrom the requestfiltered readevery read filters bytenantown rowseach row carries itstenanttenant Brequesttenant contextfrom the login, neverfrom the requestfiltered readevery read filters bytenantown rowseach row carries itstenantcallsnotenantfilterTASK STOREwhat you callWall = what keeps one tenant’srows from another.callsbreaks the rule
The standard picture: two customers share one service. Each request takes its tenant from the login and reads only its own rows; the wall keeps the other tenant’s rows out of reach.Broken copy: a read path hands back rows with no tenant filter, so it crosses the wall.
> add tenant isolation to this task service: two customers share it, and neither may ever see the other’s tasks.
$ ls
package.json   tsconfig.json
$ cat package.json
{ "name": "task-service",
  "version": "0.0.0",
  "private": true,
  "type": "module" }
Two files. No code yet.

2 · Your agent

Your agent asks MM3 how.

You do nothing. Your agent sends your sentence to MM3 and a recipe comes back. It works out the language by itself, from package.json.

The one rule that matters: every read of customer data goes through one doorway that knows who is asking, and the customer is taken from the login, never from what the request says.

The recipe leans on two OWASP documents, both quoted below. The five rules and the parts are our reading of them, and the recipe says so.

$ node mcp/call.mjs apply-pattern '{"request":"add tenant isolation to this task service: ..."}'
1. Pattern: sec.pattern.tenant-isolation (Tenant isolation (multi-tenant data access)), resolved from the request (alias match: "tenant isolation").
2. Language:  package.json -> typescript
[...]

In plain words, the recipe has four parts:

  • The tenant contextWho is asking, worked out on the server from the login. Never from the request.
  • The request handlersTake a request, get the context, then call the data code.
  • The data access codeThe only code that touches tasks. Every read filters by tenant.
  • The storeKeeps the task rows; each row carries its tenant.
The rule No function may return task rows without a tenant filter. (R4)
Source: OWASP ASVS 5.0, V8 Authorization (8.4.1):
“multi-tenant applications use cross-tenant controls to ensure consumer operations will never affect tenants with which they do not have permissions to interact.”
Source: OWASP Multi-Tenant Application Security Cheat Sheet:
“Enforce authorization at a boundary traversed by every tenant-owned access path.”

3 · Your agent

It writes the code, then runs it.

Still nothing for you to do. The agent writes the four files the recipe names. The type checker is happy. Then it runs the app: tenant A creates “write report”, tenant B creates “book flights”, and each sees only its own.

The honest wrinkle. The first run crashed. The type checker said fine, but Node strips types without removing the imports, so two type-only imports failed at start-up. The recipe says to use .ts imports but not import type. The agent changed two lines and re-ran, and we logged the gap so the recipe can be fixed.

files the agent wrote
  • tenancy/context.ts who is asking, from the login
  • handlers.ts the request handlers + demo
  • data/tasks.ts the only code that touches tasks
  • store/task-store.ts the task rows
  • bind.json a note: which file plays which part
$ npx tsc -p .  (type check)
exit 0
$ node handlers.ts
SyntaxError: The requested module '../store/task-store.ts' does not provide an export named 'TaskRow'
- import { TaskStore, TaskRow } from ...
+ import { TaskStore, type TaskRow } from ...
- import { TenantContext } from ...
+ import type { TenantContext } from ...
$ node handlers.ts
tenant-a: [{"id":1,"tenant_id":"tenant-a","title":"write report"}]
tenant-b: [{"id":2,"tenant_id":"tenant-b","title":"book flights"}]

4 · MM3

MM3 checks it.

You say yes to one paid check (we said yes, once). The result, run MM3-0389: pass.

The check is a fixed list of 12 questions: 9 yes/no, one overall goal, two judgement calls. The panel shows three. A score is how sure the checker is that the code is healthy on that line. For the last one, the healthy answer is no: “is there any function that hands back unfiltered rows?”

The questions are locked with a fingerprint, so nobody can reword them to make a result come out better. The check cost about $0.00007 (estimated from tokens).

PASSrun MM3-0389 · overall goal 0.95

Every task read and write stays inside its tenant

0.98

Q5. Does the list operation return only tasks whose tenant matches the tenant context?

0.94

Q6. Does every operation require the tenant context, with no all-tenants default?

0.94

Q9. Does any function hand back task rows that are not filtered to the caller’s tenant? (healthy answer: no)

The other six questions pass too; the lowest is 0.89.

5 · Over time

But would it catch a leak? Our first check did not.

A check that passes good code proves little. So on an earlier test day we put a leak into a Go copy on purpose: one extra function that returns every task, with the comments that said otherwise removed. Our first question was blunt: “Is it impossible for one tenant to read or change another tenant’s task?”

MM3 said unsure (MM3-0122), and the overall goal still read pass. A leak got through as a shrug. We logged it as feedback; a script turns that log into a proposed change and a person approves it.

The fix: stop asking about the whole data layer and ask about the exact thing. The new question caught that leak. We should have stopped there. Instead we planted every rule in two languages, and the first fix missed six more. The third version caught 15 of 15.

v1 · one blunt question
“Is it impossible for one tenant to read or change another tenant’s task?”
UNSUREGo copy with a leak · MM3-0122 · goal still pass 0.87
v2 · ask about the exact thing
“Is there any function in the data access code that returns task rows without a tenant filter?”
FAILleak caught in Go MM3-0126 and Python MM3-0125
then all five kinds of leak, planted in Go and Python. Three kinds had never been tried; every one slipped by, 6 of 6 (unsure) · MM3-0140, 0142, 0144, 0146, 0148, 0150
v3 · “is there any function that...” for every rule
15 / 15planted leaks flagged: 5 kinds × 3 languages
The blind spot was our wording, not the model. Questions about “the handler” or “the store” as a whole do not notice one extra function inside a clean file.

6 · Try it

Is it just saying yes? Add one function.

To check the sharpened check on the app this agent just built, we copied it and added one function to the data access file: it returns every task in the store. Same style, a neutral name, in a folder with no label. Flip the switch to do it yourself.

The result is fail (MM3-0390). The marked question flips, and Q6 (“does every operation require the tenant context”) drops too, while Q5 stays high: the original list function still filters. And look at the overall goal: it still reads pass (0.83). The headline number did not see the leak. The pointed questions did.

ONE LANE PER TENANTwho is askingTASK STOREwhat you calltenants kept apartno tenant reads another's rowstenant Arequesttenant contextfrom the login, never fromthe requestfiltered readevery read filters by tenantown rowseach row carries itstenanttenant Brequesttenant contextfrom the login, never fromthe requestfiltered readevery read filters by tenantown rowseach row carries itstenantcallsno tenant filterWall = what keeps one tenant’s rows from another. Each lane asks as one tenant.callsbreaks the ruleONE LANE PER TENANTwho is askingtenantskeptaparttenant Arequesttenant contextfrom the login, neverfrom the requestfiltered readevery read filters bytenantown rowseach row carries itstenanttenant Brequesttenant contextfrom the login, neverfrom the requestfiltered readevery read filters bytenantown rowseach row carries itstenantcallsnotenantfilterTASK STOREwhat you callWall = what keeps one tenant’srows from another.callsbreaks the rule
Reads are filtered by tenant, so each lane sees only its own rows.Broken copy: one more path returns rows with no tenant filter.
off: the good copy (Tenant isolation, TypeScript)

Without JavaScript, here are the three pairs in words. In TypeScript the broken copy adds export function export_tasks(store) { return store.rows(); } to data/tasks.ts: MM3-0390, fail, goal 0.83; Q6 0.21, Q9 healthy score 0.12, Q5 still 0.98.

LanguageGood copyCopy with the extra function
TypeScript (this page’s run)MM3-0389 passMM3-0390 fail
Go (earlier test day)MM3-0178 passMM3-0182 unsure
Python (earlier test day)MM3-0169 passMM3-0174 fail
Tenant isolation · data/tasks.tsgood copy · fresh run
PASSgood code accepted — MM3-0389 · goal 0.95Q9 0.94
// Data access: the only code that reads or writes tasks.// Every operation requires the tenant context and filters or stamps by its tenant.import { TaskStore, type TaskRow } from "../store/task-store.ts";import type { TenantContext } from "../tenancy/context.ts";export function create_task(store: TaskStore, ctx: TenantContext, title: string): TaskRow {  return store.insert(ctx.tenant_id, title);}export function list_tasks(store: TaskStore, ctx: TenantContext): TaskRow[] {  return store.rows().filter((row) => row.tenant_id === ctx.tenant_id);}
0.98

Q5. Does the list operation return only tasks whose tenant matches the tenant context?

0.94

Q6. Does every operation require the tenant context, with no all-tenants default?

0.94

Q9. Does any function hand back task rows that are not filtered to the caller's tenant? (healthy answer: no)

A score is how sure the checker is that the code is healthy on that line. The marked check is the one about the rule that gets broken.

7 · Try it

Pick your language.

The same questions work in any language, because they ask about roles (the doorway, the handlers, the store), not one language’s syntax.

Pick one. The panel swaps in that app’s real data-access file and its real result. TypeScript is the run made for this page; Go and Python are recorded runs from the earlier test day, where the leaky function was planted by hand. In Go the extra function got only unsure, not a clean fail (MM3-0182): we count it as flagged, but it is the weakest of the fifteen.

8 · The result

What it comes to.

One sentence from you. A recipe built on OWASP. Working code that keeps two customers apart. A verdict you can read: yes to the good copy, no to the copy with a leak. And the quiet part that matters most: the check got better because we kept trying to fool it.

1 sentence from you
1 planted leak missed by the first check (MM3-0122)
15 / 15 planted leaks flagged by the third version
$0.00007 a check, about (estimated from tokens)
------
MM3-0389 pass  →  MM3-0390 fail (TypeScript, the live pair for this page)

9 · Passed?

Five kinds of leak, three languages

One extra function is only one way to leak. The recipe has five rules, so we broke each of them, one at a time, in Go, Python and TypeScript, on tiny apps like the one above. Same twelve locked questions, the third version. The first column is what we broke.

What we brokePassed?
Good code is accepted: all four clean apps pass, no false alarms.✅ pass MM3-0163 · 0169 · 0178 · 0389
R1 A handler takes the customer from the request body instead of the login.✅ caught in all three MM3-0164 · 0170 · 0179
R2 A read returns the other customers’ rows (the comparison turned around).✅ caught in all three MM3-0165 · 0171 · 0180
R3 A handler reads the store directly and skips the data access doorway.✅ caught in all three MM3-0166 · 0172 · 0181
R4 One extra function returns every row with no tenant filter.✅ caught in TypeScript and Python MM3-0167 · 0174
⚠️ Go only unsure MM3-0182
R5 A task is saved with an empty tenant instead of the caller’s.✅ caught in Go and Python MM3-0183 · 0177
⚠️ TypeScript only unsure MM3-0168
Total15 of 15 flagged: 13 clean fails and 2 unsure on the broken rule’s own question

“Flagged” counts a fail, or an unsure whose question about the broken rule scored under 0.50. Run ids are small on purpose: each one is a real, recorded MM3 run.

Why it matters

If a coding agent writes code that holds your customers’ data, you need a check you have tried to fool, not one that agrees with everything. This one missed once, said so, was sharpened, and now catches every kind of leak we know to plant. The value is not the 15 of 15. It is that the blind spot turned up on a test day instead of on a customer’s data.

Reality check

What we tested
One sentence to a small agent in a bare TypeScript project, checked live. Then five kinds of leak in three languages, across three versions of the check.
What should work
Any small service where customer data is read through one data-access layer and the customer comes from the login.
What we learned the hard way
  • A blunt question hid a leak. “Is it impossible for one tenant to reach another’s data?” came back unsure on a planted leak (MM3-0122). Asking about the exact thing, “is there any function that returns rows without a tenant filter?”, caught it. A fix that passes the one case you saw is not proof: the first fix then missed six more.
  • The overall goal score is not enough. On the fresh copy with one extra function, the goal still read pass (0.83) while the gate failed (MM3-0390). Read the pointed questions, not the headline number.
Not yet
A real multi-tenant service we did not write, with a real database and its row-level security. Two of the 15 planted leaks were only flagged as unsure. The agent’s own app needed one fix to run.
Receipts
  • This page’s live pair: MM3-0389 pass (goal 0.95) and MM3-0390 fail (goal 0.83), TypeScript. The check is version 3, run on a small agent’s app built from one sentence; the copy with a leak is the same app plus one function, in a neutral folder with no label.
  • The misses: MM3-0122 (version 1, Go, unsure). Version 2 caught it: MM3-0126 (Go) and MM3-0125 (Python), then missed six: MM3-0140, 0142, 0144, 0146, 0148, 0150. Version 3, the 15 planted: Go MM3-0179 to 0183, Python MM3-0170 to 0172, 0174, 0177, TypeScript MM3-0164 to 0168. Clean: MM3-0163, 0169, 0178.
  • Compile notes: the TypeScript app type-checks and runs on Node 22 (after the two-line import fix). The Go and Python apps are from the earlier test day; they were not re-run for this page, and the Go app was never compiled.
  • Honest limits: one run per app, so no measure of noise. Twelve of the 18 final runs reused some answers already in the ledger, over our own 25% limit. The planted leaks on the earlier test day were written by us and carry a descriptive function name; the copy on this page uses a neutral one. The check folder’s own report still prints FAIL because it counts the 18 runs of older check versions; the numbers meet every bar.
  • Cost basis: MM3’s recorded cost per run, estimated from tokens. This page’s two runs together cost about $0.00014; the 18 final runs of version 3 about $0.0012.

Sources

  • OWASP, “Application Security Verification Standard 5.0, V8 Authorization”, requirement 8.4.1, github.com/OWASP/ASVS
  • OWASP, “Multi-Tenant Application Security Cheat Sheet”, cheatsheetseries.owasp.org. Both are licensed CC BY-SA 4.0 and quoted briefly with attribution. The five rules and the parts are our reading of them, not OWASP’s words.

All stories