Daily demo · 5 Nov 2026 · Northwind Support Gateway

Similarity is not a tenant boundary

Toy embedder. Hashed words, word pairs, and character trigrams, then cosine. Not a production model. No network, no API key. The 0.88 cancel-versus-upgrade figure is from the field note in the sources. Every score below is this page’s.

Northwind’s support gateway sits in front of the help-desk model for two SaaS customers, Acme Corp and Globex Inc. A semantic cache reuses a stored answer when the new question is close to an old one, so a password paraphrase can skip the model. The savings are real. So is the leak, when the nearest answer belongs to the other company and names their SSO domain and account manager.

Global pool, or a soft prefix

The winner is whoever sits nearest in the vector. Mixing tenant:globex into the text nudges the vector. A long question still outvotes a short token, so Acme’s row can clear the threshold.

Hard namespace

Drop every row whose tenant id is not the caller’s. Then run cosine on what remains. A closer Acme answer never enters the ranking. A high threshold is the second check, for cancel versus upgrade inside one tenant.

Look up a question

Calling tenant

Calling as Bob Okonkwo, Globex Inc, Growth tier.

Try a stored question, a paraphrase, or cancel versus upgrade.

0.70 loose0.99 strict

Cancel versus upgrade is a known false-positive pair, reported near 0.88 cosine.

Scoping

  • None. One pool. Cosine is the only key.
  • Soft prefix. tenant:acme or tenant:globex is mixed into the text, then embedded. The other tenant’s rows still compete.
  • Hard namespace. An exact filter on tenant id runs first. Then cosine ranks the rows that remain.

What the lookup did

Run a lookup.

Eligible rows, nearest first

Only the top row is served. The rest show how close a wrong answer sat. The mark on the bar is the threshold.

Cached questions

The embedder reads the question only. The answer can name a person, a domain, and a contract term the question never mentioned. Globex has no cached cancel answer, which is how “cancel” can land on “upgrade.” Alice’s personalized reset is added by Replay leak scenario.

What a team would change

Filter on tenant id first

The namespace is metadata, compared exactly, before nearest-neighbor. Redis puts that filter inside the vector query. A token glued onto the sentence is a nudge, and a nudge loses to a long question. The vLLM semantic-router issue recorded the miss: a namespace token repeated three times still returned another user’s answer at similarity 0.9085 against a threshold of 0.8.

function eligible(entry, caller, mode) {
  if (mode === "hard") return entry.tenant === caller;
  return true;
}

Treat the threshold as risk

0.85 feels like a reasonable default and still serves upgrade for a cancel question when the right row was never stored. Hold the line high, tune it on real traffic, and keep a second check for intents a single word apart. Cache FAQ and docs. Leave contract terms and named people out, or key them to every identity allowed to read them.

function embedText(question, tenant, mode) {
  if (mode === "soft") {
    return "tenant:" + tenant + " " + question;
  }
  return question;
}

Log both tenants on every hit

Write the caller’s tenant and the winner’s tenant. Alert when they differ. A dashboard that only tracks hit rate will celebrate the leak, because the leak is a hit: 18 ms and $0 instead of the model. Authenticate and authorize before the cache lookup, so a stored answer cannot skip a policy that would block the live call.

Inspired by