---
title: An AI assistant needs one door into your data, not a key to every SaaS app
description: Steward gives Claude a single read-only tool over the curated warehouse, with field-level authorization compiled to Cedar. Here is why I built it that way instead of wiring up one connector per product.
date: 2026-09-24
area: ai
tags:
  - agents
  - architecture
  - patterns
draft: false
---

The first thing anyone asks for once Claude is in the building is "can it answer
questions about our customers?" The reflex answer is to connect it to the systems that
hold customers: an MCP server for the CRM, another for the ticketing system, another for
billing, another for the product analytics tool. Six weeks later there are a dozen
connectors, each with its own login, its own idea of what an "account" is, and its own
per-seat fee. The assistant has forty tools to choose from on every turn, and when
someone asks for a customer's revenue it gets three different numbers depending on which
tool it picked.

I built Steward, an open-source Rust service, because that reflex solves the wrong
problem. The hard work of reconciling those systems has already been done, by the data
team, in the warehouse. Finance already argued about what counts as revenue. Someone
already decided which accounts are test accounts. The curated tables are where the
organization agreed on the answers. So put the assistant on those tables, behind one
door, and make the door enforce who may see what.

## One tool, and adding data means adding a file

Steward exposes exactly one MCP tool, `read`, and it routes on a path the way a URL does.
`/` is the catalog of kinds. `/ACCOUNTS` describes what an account has and what this
caller may search on. `/ACCOUNTS?q=acme` searches. `/ACCOUNTS/A-17` reads one. And
`/ACCOUNTS/A-17/links` lists what else in the warehouse points at it. The same routing is
served as plain REST under `/v1/` for programs, from the same implementation, returning
the same documents.

The alternative, one tool per operation per dataset, makes the model classify its own
intent before it knows what it needs, and the tool list grows with every table. A single
routed tool means the model learns one thing. Adding a dataset is a JSON5 schema file
that names the table, the fields, their classifications and their relationships. No
code, no new tool, no release. The schemas are validated together at boot, so a typo in
a relationship target fails the deploy rather than a request at two in the morning.

## The authorization is the product

Everything else is plumbing. What makes this safe to hand to a whole company is that
every field carries a classification, and every request is authorized field by field.

Fields are grouped into sections, and each section has one classification level. The
deployment declares its own taxonomy: `public` and `internal` are baseline, granted to
anyone with a role. `confidential_pii` and `confidential_financial` need an explicit
rule. A level marked `searchable: false` can be read one record at a time but never
searched, filtered or sorted, so customer-written free text can be opened from a link
but not trawled. A level marked `restricted: true` is withheld even from the admin role.

At boot the taxonomy and the access rules compile into Cedar policies and are validated
against a fixed Cedar schema. `read` and `search` are separate actions, so "look at one
record, never trawl" is a policy, not a prompt instruction. Every non-searchable level
also gets an unconditional `forbid` on search, and Cedar's forbid-overrides-permit rule
makes that impossible to undo with a later permit. Running `steward policies` prints the
whole set, which is the evidence an auditor asks for when they want to know who can read
what. Masking lives in the schema rather than in policy: an email appears masked in the
`internal` section and raw in the `pii` section, and Cedar decides which of the two a
caller gets.

The whole thing fails closed. An unknown level, an unknown section, a caller with no
roles: nothing is granted. Row-level security, where visibility depends on which rows a
caller owns, is deliberately out of scope, because a shared cache cannot serve it
safely. That is stated in the decision record rather than left for someone to discover.

## Tell the model what it does not know

MCP tool results are text a model reads and reasons about, and models over-read them.
An empty page looks like "there are none". A missing total looks like zero. So every
response is a small hypermedia document: the properties grouped by section, a list of
ready-made links the model passes back verbatim, and an action describing what this
caller can actually search on. Pagination is "follow the `next` link", not "compute the
offset". Relationships are links derived from the schema, so account to contacts to
their tickets is a sequence of link follows with no query construction by the model.

Uncertainty is stated in the document rather than implied by its shape. A search that
could not count says `total_unknown`. A search narrowed by what the caller may see says
`search_narrowed`. Every value is a string and NULLs are omitted, so absent never looks
like zero. Search results are links, not rows, which means every row a caller ever sees
went through the read check.

## Refuse the wrong number, and point at the right one

The number that gets a company in trouble is the plausible one. Sum a per-account revenue
column across five pages and you get a figure that looks authoritative and disagrees with
what finance published, because the governed definition excluded some accounts and
applied a currency rule the rows do not show.

A field can declare it is governed by a named view. A point read still returns the value,
marked with a note not to aggregate it. Filtering or sorting on it is refused with a
`governed_metric` error that names the view to use instead. A kind can also declare a
governed population: rows where a flag such as `IS_TEST_ACCOUNT` is true are removed
from every search in SQL, and a point read of one returns the same `not_found` as a
missing id, so the refusal does not confirm the row exists. That check runs on cached
rows too, because a rule that only works when someone remembers it is not a rule.

## What it costs

The response format is bespoke, so a client written against HAL or Siren needs an
adapter. Seeing a search result's rows takes one extra call per row. The registry of
governed views is maintained by hand and describes the warehouse as of when it was
written. Analytic warehouses are built for scans, so point reads are slow and cost
compute, which is why there is an optional cache with stale-while-revalidate, and why
searches always go live. Each of those is written down as a consequence in the decision
record that created it, which is the part of this project I would defend hardest.

## Where it is

The server speaks MCP and REST, reads from Snowflake or Amazon Redshift, validates OIDC
tokens against any identity provider, and ships under Apache 2.0. It comes with a
fictional SaaS company's data, twenty-two kinds across CRM, support, billing, HR and
recruiting, with scripted storylines and questions that have known answers, so you can
check what the assistant says against what is true. It is young, and the schema surface
will move. The question I keep coming back to is not whether one door is better than
twelve. It is how much of the data a company already trusts is sitting in the warehouse,
unused by the assistant, because nobody built the door.
