MOD · Dispatch · Blog S/N · 10 MIN · OCT 8, 2026

10 min read

The privacy setting that enforced nothing

A privacy toggle that changes a label and nothing else is worse than no toggle, because the user stops looking. Team-X shipped one. v3.5.0 enforces the tier on every model call and deletes every control it could not back.

  • privacy
  • trust
  • release
  • settings
  • local-first
  • engineering

One setting in Team-X promised that no data would leave your machine and checked nothing. Team-X shipped it that way. The v3.5.0 changelog says so in plain words, and I am going to repeat them here instead of softening them.

The industry treats a settings page as interface. I think it is a list of promises, and a promise nothing enforces is a liability.

Team-X, an open-source, local-first desktop app for running AI-agent organizations, shipped v3.5.0 on 2026-10-08. Its privacy tier now gates every model call, embedding, and external runtime at call time. A provider above the tier is refused with a named error and is never silently replaced. Settings that could not be enforced were deleted.

What did the Local Only setting actually do?

It drew a flag. The v3.5.0 changelog puts it this way: “Local Only” promised “No data leaves your machine”, but max_privacy_tier only drew an “allowed” flag in the panel; nothing that chose a provider read it.

That is the whole defect. The panel stored a value and displayed a badge. The code that picked a provider never asked what the value was.

I can check one thing about the history. The setting entered the codebase in commit a4d4e6a on 2026-04-12. At v3.4.0, tagged 2026-07-11, the only non-test references to max_privacy_tier in main-process code were the default value (proprietary-cloud) and the IPC handler that saves it. I did not audit every commit between those two dates, so I will not put a number on how long the gap lasted.

The customer-facing summary states the consequence directly: “Local Only” did not stop a cloud call.

The engineering audit dated 2026-10-07, which this release answers, listed it as a finding: privacy-tier enforcement was “not consistently applied in provider construction/runtime selection”. The changelog is the document that describes what a user would have seen.

Why is a decorative control worse than no control?

Because the control answers the question for the user. At v3.4.0 the Privacy panel printed the word Allowed or Blocked beside each provider. Someone who sets Local Only and reads Blocked next to a cloud provider stops checking. A missing toggle at least keeps them suspicious.

Nielsen Norman Group’s first usability heuristic says the design should always keep users informed about what is going on, “through appropriate feedback within a reasonable amount of time.” A badge that reports “allowed” while nothing enforces anything is feedback, but it is feedback about the wrong thing.

Regulators have read this pattern as a problem about claims, not about code. In its 2020 Zoom announcement, the FTC said: “In reality, the FTC alleges, Zoom maintained the cryptographic keys that could allow Zoom to access the content of its customers’ meetings, and secured its Zoom Meetings, in part, with a lower level of encryption than promised.” Those were allegations in a complaint that ended in a settlement. The lesson I take is narrow: a security label has to match what the product does.

The Google Incognito case points the same direction. The Register reported that the settlement requires Google to “delete and/or remediate billions of data records” reflecting class members’ private browsing activities. Different company, different mechanism, same shape: a mode named for privacy that users read as a promise.

The FTC also said, when it announced its September 2024 AI enforcement sweep, that there is “no AI exemption from the laws on the books.” That standard applies to anyone who labels a control.

How is the privacy tier enforced now?

At the moment of the call, in three places, with one shared rule. A change to the setting applies to the next call, with no restart.

The changelog lists the paths. The provider factory (create and resolveForEmployee) is checked at every construction site in main/index.ts, covering chat, tickets, delegation, meetings, Copilot, and Enhanced AI. Every embedding call is checked per embed(). External runtime profiles, which never went through the factory, are now checked too.

The rule itself is a rank comparison in @team-x/shared-types. Here it is at v3.5.0, verbatim:

export function exceedsPrivacyTier(providerTier: string, maxTier: string): boolean {
  const providerRank = Object.hasOwn(PRIVACY_TIER_RANK, providerTier)
    ? PRIVACY_TIER_RANK[providerTier as PrivacyTier]
    : Number.POSITIVE_INFINITY;
  const maxRank = Object.hasOwn(PRIVACY_TIER_RANK, maxTier)
    ? PRIVACY_TIER_RANK[maxTier as PrivacyTier]
    : PRIVACY_TIER_RANK.local;
  return providerRank > maxRank;
}

Read the two fallbacks. A provider tier the code does not recognize ranks as least private. A max tier it does not recognize, such as a corrupted settings row, ranks as Local Only. Both fail closed. The source file says the Privacy panel and the factory share this one copy, “so the two cannot disagree.”

Before this, the panel kept its own copy of the rule. Two copies of a security rule is how a display and an enforcer drift apart.

Embeddings needed special handling. The retrieval adapter is built once at startup, so a build-time check would never see a later tier change. The code comment explains that the adapter “re-reads it on EVERY embed() call” and rejects before any text reaches a provider above the tier.

External runtimes are classified by destination. Per the changelog, Codex, Claude Code, and Cursor count as Proprietary Cloud. A command runtime also counts as Proprietary Cloud because its destination is unknowable. An HTTP runtime is Local only when its host is provably on the local network.

Why refuse instead of falling back to another provider?

Because a silent swap is the same lie in a different place. If Local Only quietly routed a request somewhere else, the user would see an answer and learn nothing about the policy that just fired.

The changelog is explicit: a refused provider is never silently re-routed. The run fails before any key is read or process spawned. The error is a PrivacyTierViolationError, and it names the provider, both tiers, and the way out. The changelog’s example reads Provider "Anthropic (claude-haiku-4-5)" is Proprietary Cloud-tier, followed by the allowed tier, and ends with Choose a local provider (Ollama) for this employee or raise the privacy tier.

The class carries those values as structured fields too (providerId, providerName, providerTier, maxTier), so callers branch on data, not on message text. It lives in provider-factory.ts.

A refusal has to be visible, or it looks like a bug. The changelog records that the renderer never displayed the reason in a work.failed event, so a refusal “looked like an employee ignoring you.” Turn failures now show under the transcript and on timeline rows. That fix shipped in the same release because enforcement without explanation is just a new kind of silence.

One carve-out keeps Local Only usable. Built-in defaults the tier forbids are skipped, so an employee with no explicit provider is served by an allowed default, such as a configured Ollama, instead of being refused. An explicit choice, from the employee or the company default, is still refused and never swapped.

This is also the practical reading of data protection by default. GDPR Article 25(2) requires that, by default, “only personal data which are necessary for each specific purpose of the processing are processed.” OWASP says the same in engineering terms: configure systems to be secure by default and “fail securely”. Refusal is what failing securely looks like to a user.

Which settings did I delete instead of fix?

Four settings and one command-line tool. For each, the question was whether any reachable code could honor the value. Where the answer was no, the control went.

ControlWhat the changelog saysv3.5.0 action
Enhanced AI, Streaming Responses and Multi-Turn PlanningOnly Enhanced AI paths the app never calls read themRemoved
Enhanced AI, Max Tokens and TemperatureNo provider adapter accepts a token cap or a temperatureRemoved from panel, IPC contract, and handlers
team-x-ai CLIEvery CLI command printed fabricated output (and ran twice)Removed; the tested evaluator stays, reachable through AiService.evaluate
Cloud workspace linking, operator invites, cloud identityA reserved link lit a green Linked lamp and invented a last-sync timeKept, labeled as a local-only preview; lamp and fake time removed
Agentic loop and Copilot runtime profilesA runtime profile bound to those agents was ignoredFixed; they resolve through the same policy as chat

Deletion beat fixing for the first three rows for one reason. Fixing a control means building the capability behind it. Streaming, multi-turn planning, token caps, and temperature all need changes to the provider stream contract. The changelog says the guard keeps Max Tokens and Temperature out “until the provider stream contract can carry them.”

I would rather ship a smaller settings page that is true than a larger one that is mostly decoration. A deleted setting costs a user nothing they had. A fake one costs them trust the first time they notice.

The preview row is the opposite case. The feature stays, and the interface stops pretending. A reserved cloud link no longer lights a green lamp, and copy no longer claims invites are sent or memberships mirrored.

What keeps a setting from lying again?

Tests that read the source. The release adds guards in two shapes, and I want to be straight about what they do and do not prove.

The first shape pins enforcement wiring. composition-root-wiring.test.ts opens with the failure mode it exists to catch: a provider factory built without getMaxPrivacyTier happily calls a cloud provider under Local Only. It then asserts that every createProviderFactory, buildEmbedAdapter, and createRuntimeProfileProviderService call in the boot sequence passes getMaxPrivacyTier.

This matters because the factory has an escape hatch by design. Per the source comment, with no tier getter there is “no enforcement” so unit suites keep working. A new construction site that forgets the argument would fail open. The pin makes forgetting a red build.

The second shape pins absence. settings-cluster-sweep.test.ts has cases named “offers no Multi-Turn Planning or Streaming Responses control” and “offers no Max Tokens or Temperature control.” They assert the removed control ids are not in the Enhanced AI panel source.

The limit is plain. These are source pins. The wiring test itself says it reads source because the composition root cannot be constructed under Vitest. A source pin proves the argument is passed. Behavioral tests in provider-factory.test.ts prove the refusal fires. Neither proves there is no fourth path I have not thought of.

What still is not guaranteed?

The changelog tells me what the release does not claim, and I should not claim more.

It says v3.5.0 ships unsigned: no signing credentials are configured yet, so the override is set and the release notes say so. A privacy post that hid that would be doing the thing it argues against.

The enforcement list is the changelog’s list: factory, embeddings, and runtime profiles. A path that reaches a model outside those is a bug I want reported. The audit this release answers is also why I trust the list more than my own memory of it.

Existing users face a behavior change. The user guide warns that if you set Privacy to Local Only or Open-Source Cloud while employees used cloud providers, those employees will now stop with a clear refusal instead of quietly using the cloud.

A setting that cannot fail is a setting that cannot be trusted.

What should you do with your own settings page?

The standard is small enough to adopt this week. Every control either has a code path that enforces it, or it is deleted. It is the rule I already had to apply to docs that described a product that did not exist, a vector database that never ran, and 3,640 lines of code nobody could reach.

  1. List every setting in your product and, for each, name the function that reads it. If you cannot, it is decoration.
  2. Trace the read to an effect a user could observe. A value that is stored and displayed but never consulted counts as unread.
  3. Fail closed. Make unknown values the restrictive case, as exceedsPrivacyTier does.
  4. Write one test per removed control that asserts it stays absent, and one per enforcement site that asserts the check is wired.
  5. If you run Team-X, open the Privacy panel, read the list of refused providers, and set the tier you meant.

The repo is at github.com/Git-Rocky-Stack/Team-X, and the posture is written up at /privacy/. If a control in Team-X does not do what its label says, open an issue.

Frequently asked questions

Does Team-X's Local Only privacy tier actually stop cloud model calls?

As of v3.5.0, yes. Team-X, an open-source, local-first desktop app for running AI-agent organizations, checks the privacy tier at call time for chat, tickets, delegation, meetings, Copilot, Enhanced AI, embeddings, and external runtimes. A provider above the tier is refused with a named error. Before v3.5.0 the setting only drew a flag.

What happens in Team-X when a provider is above my privacy tier?

Team-X refuses the run with a PrivacyTierViolationError before any API key is read or process spawned. The message names the provider, the provider's tier, the tier your Privacy setting allows, and the way out. Team-X never silently swaps in a different provider, so you always learn why an employee did not run.

Which Team-X settings did v3.5.0 delete instead of fixing?

Team-X v3.5.0 removed Enhanced AI Streaming Responses and Multi-Turn Planning, because only code paths the app never calls read them, and Max Tokens and Temperature, because no provider adapter accepts either value. It also removed the team-x-ai CLI, whose commands printed fabricated output.

A setting is a promise. If no code enforces it, delete it.
AUTHOR Rocky Elsalaymeh PUBLISHED Oct 8, 2026 ● LIVE