Engineering·16 min read

Policy Is Prose. Compliance Is Controls.

Agents now do most of the data access in your company. The access policy you already have is the right defense, but only if its rules live where the data is served and an agent checks them on every change.

We built a real example on synthetic patient records: a HIPAA-shaped policy, a data model that carries its classes and gates, a server that refuses a model that breaks them, and the evidence an auditor would ask for.

Kyle Nesbit

Kyle Nesbit

CEO & Founder @ Credible · Nov 4, 2026

Your auditor doesn't read your policy. They read your evidence.

Most companies have a written access policy. It names roles, classes of data, and what each role may see. The systems that serve the data never read it. So when an auditor asks you to prove that only treating clinicians can read a patient's chart, you hand over screenshots of a permissions console and a spreadsheet of grants.

That held up when only people read the data. Each person used a narrow slice of it and followed the norms around them. Most data access has since moved to agents. An agent reads everything it can reach, at machine speed, without asking. And some of those agents aren't yours: a compromised integration, a partner's tool, a prompt that slipped past a guardrail. They can learn from what a refusal gives away, follow links between tables as far as they go, and piece together a protected record from thousands of small questions.

You don't need a new "AI governance" function beside the old one. You need the discipline you already have. HIPAA, SOC 2, PCI DSS, NIST 800-53 and ISO 27001 are table stakes. Real organizations add their own rules on top -- who sees revenue before earnings, which customer data stays in-region, what a contractor may touch. Industry framework or internal policy, they all reduce to the same parts: roles, classes of data, and controls you can test. So one set of primitives covers all of them.

This is the moment to double down on that discipline and extend it to the actors that aren't people. The attacker is now an agent, so the defense has to be one too.

Curate What Your Agents See showed where an access rule belongs: on the data model, the interface every person and agent queries through. This post covers where controls come from, how to prove the model follows them, and what to hand the auditor.

Policy is prose. Compliance is controls.

Global companies worked this out long ago. A multinational doesn't write one rulebook for every country. It writes a policy of principles, then breaks it into controls: short, numbered statements you can check, like "only billing staff can see claim amounts." Each country then implements the controls its own way.

Every compliance framework is built like this, and so is any internal policy worth following.

HIPAA spells this out. Its "minimum necessary" rule requires a hospital to list each kind of worker and the patient data each one needs. That's a table of roles against types of data, required by law. Every hospital has one. It's the access policy, already written and approved, and almost never connected to the systems that serve the data.

Auditors never audit the prose. They audit the controls, against evidence.

Classification does most of the work

A policy's access rules are written about classes of data, not about tables. A class is a label for what the data is. A name or a social security number is a direct identifier: it points straight at one person. A diagnosis or a prescription is clinical.

A rule about a class survives changes to the tables. "Researchers can't see direct identifiers" is still true after the next schema change. "Researchers can't query patients.ssn" stops protecting you the day someone adds another column with SSNs in it.

Agents make classification mandatory. If a field has no class, the policy says nothing about it, and an agent that queries the data will read it anyway. So the first control in any policy for agents is simple: every field anyone can reach has a class.

Whoever writes the data model assigns the classes. Doing that by hand for every field doesn't hold up, because new fields are built from old ones all the time. masked_ssn is concat('***', ssn) is still an SSN. Sooner or later, someone forgets to label one.

The class must follow the data. In the model, a field built from another field inherits its classes, whether it copies, renames, transforms or joins to it. Nobody labels it, so nobody can forget to. A field can carry several classes at once, because each set of rules has its own name for the same thing: an SSN is an "identifier" under HIPAA, "personal data" under GDPR and "PII" under NIST.

That leaves the original columns, the ones nothing was built from. Someone has to give each of those a class to start the chain, and an agent can do the first pass. It works from two kinds of evidence. The column's values set a floor: a column of record IDs, or a text column with a different value for nearly every patient, identifies people whatever it's called. The policy's own list of classes decides the rest, by what each column means. The agent writes its proposals as a diff, and nothing changes until the model's author approves it.

The author can also override an inherited class. A birth year built from a birth date reveals less than the full date, so the author can relabel it a quasi-identifier: data that can't name a person on its own but can help identify them when combined with other facts. The author's class wins, and the model still records where the field came from, so a reviewer can always ask why.

A real example: synthetic patients, a real policy

To show this working, we built a complete example in the open, on data that looks exactly like real patient records and isn't. It runs on Malloy Publisher, the open-source server that serves Malloy data models and enforces their access rules.

Synthea generates realistic, made-up patient records from population statistics. Its sample has 1,171 patients, along with their visits (called encounters), diagnoses, medications, procedures, test results and insurance claims. The patient table alone holds SSNs, driver's licenses, passports, names, birth dates, street addresses and map coordinates. That's ten of the eighteen kinds of identifier HIPAA says must be removed before data counts as de-identified.

The policy belongs to a fictional health system, Riverbend Health, and is written the way a real hospital's would be. It defines:

  • Seven roles, from treating clinician to billing staff to approved researcher to outside partner.
  • Five classes of data: direct identifiers, quasi-identifiers, clinical, financial, and none. none is a deliberate class, not a missing one: it marks data the author checked and found needs no protection, like a patient's state of residence.
  • Numbered controls, grouped by purpose: CL- for classification, AC- for access, MN- for minimum necessary, what each role may see, DI- and LDS- for two levels of stripped-down patient data (de-identified data and limited data sets), and AU- for audit.
POLICYMN-01: a clinician readsfull PHI only for patientsseen at their organizations.CONTROLSCL-01AC-03MN-01DI-01AU-02MODELclinical-records.malloypatient_chart#(authorize) 'clinician'research#(carries) [ … ]dimension: age#(classification) …EVIDENCEReview reportpass or fail forevery controlPer-query recordwho asked, whichfields, which classesrefuses at load whatbreaks a control
Figure 1A policy becomes numbered controls, the model enforces them with classes and gates, and the model produces the evidence.

The data model has three layers, the same structure Curate What Your Agents See walked through:

  1. Base sources, one per table, locked so no one can query them directly.
  2. The core, one source per kind of thing: patients, encounters, claims. Each hides the raw columns and re-exposes them under clear names, with a class on every field.
  3. Analytical domains, the five sources that people and agents actually query, one for each access decision in the policy: the patient chart, billing, population health, a partner data set and research.

Each domain has a gate, a rule about who may use it. Some also filter rows: a clinician sees only patients at their own hospitals. Here is the research domain. The annotations above it say who may use it and which classes of data it may return, right next to the definition of the data itself:

#(secure)
given: ROLES :: string[]

#(authorize) 'researcher' in $ROLES
#(carries) ["phi.quasi_identifier", "phi.clinical", "phi.financial", "none"]
source: research is encounters_deid extend {
  join_one: patient is patients_deid on PATIENT = patient.Id
  join_many: conditions is conditions_deid on Id = conditions.ENCOUNTER
}

And here's how age is made safe for research. Very old ages are rare enough to point to a specific person, so HIPAA requires every age over 89 to be grouped together. This field reports any age over 89 as 90, and every domain that needs age uses it:

#(doc) Age in whole years on 2020-04-30, reported as 90 for every age over 89.
#(classification) "phi.quasi_identifier"
dimension: age is
  pick 90 when floor(days(BIRTHDATE to @2020-04-30) / 365.25) > 89
  else floor(days(BIRTHDATE to @2020-04-30) / 365.25)

These annotations carry the whole policy:

  • #(authorize) is the gate: the condition a caller has to meet to use the domain. A row filter, #(access_filter), narrows what they see once inside.
  • #(classification) says what a field is, using the policy's own class names: one class, or a list of classes when the field falls under several sets of rules. Publisher never interprets the names, so phi.direct_identifier, pci.pan, confidential and your company's own classes all work the same way.
  • #(carries) lists the classes a domain is allowed to return.
  • #(secure) means a value comes from the login system, not from whoever is asking. A caller can't claim a role or an organization they don't have. Without it, a gate is just a filter the caller can change.

The model carries what Publisher enforces. The policy's numbered controls live in a separate file beside it, the control register, which names each control and the check that proves it. The next sections run those checks: some Publisher runs itself, and the rest an agent runs.

Publisher refuses what's inconsistent

A control that's checked once a quarter is a promise. A control Publisher checks every time the model loads is a guarantee.

To test that, we built five broken versions of the model, each a realistic mistake. Three of them contradict what the model itself says, and Publisher refuses to run them at all. For each one: the change, why it's a problem, and the first line of the error Publisher returned.

Research joins back to the full patient record (variant 01)

The change: someone adds a join for "patient context" that brings in each patient's name and SSN.

Why it's a problem: research is supposed to return de-identified data only. Now it returns SSNs.

Publisher's response: it refuses to load the model and names the field that broke the rule:

source research (domains.malloy) carries [none, phi.clinical, phi.financial, phi.quasi_identifier] but exposes person.ssn, classified phi.direct_identifier.

The clinicians' hospital filter trusts the request (variant 04)

The change: the list of hospitals a clinician belongs to loses its #(secure) annotation.

Why it's a problem: that list now comes from the request instead of the login system, so a clinician can name any hospital and read its patients.

Publisher's response: it refuses to load the model and says the filter can be set by the caller:

source patient_chart (domains.malloy) is gated on $ORGANIZATIONS, which is not declared #(secure), so a caller can set it and the gate is a filter, not a control.

Billing is opened to everyone (variant 05)

The change: billing's role check is replaced with #(authorize) true, which lets anyone in.

Why it's a problem: billing joins to the locked patient records, so anyone could now read them through billing.

Publisher's response: it refuses to load the model:

source billing (domains.malloy) is open to every caller and joins patient, which is locked (#(authorize) false).

These refusals happen when the model loads, so they work like a failed build. The model never goes live, the error names the problem, and the message says how to fix it. That's the policy's own control AU-03 -- a change that would violate a control is refused, not deployed with a warning -- enforced by Publisher instead of by someone remembering to check.

The design also fails safe. If someone deletes a domain's gate entirely, the domain doesn't open up. Every domain is built on a locked core, so a domain with no gate of its own lets no one in.

Consistent, and still wrong

The other two broken versions load without an error, because nothing in them contradicts anything else in the model.

In one (variant 02), the population health domain, a limited data set, returns each encounter's record number and labels it #(classification) "none", meaning nothing to protect. The labels agree with each other. One of them is simply wrong: a record number can be traced back to a patient.

In the other (variant 03), billing keeps its role check but loses its hospital filter. That's a normal-looking design, so there's nothing for Publisher to object to. But control MN-02 says billing staff see patients "for the organizations they bill for," and now a biller at one hospital can see every hospital's encounters: 53,346 instead of 1,028.

Publisher doesn't catch these, and no tool should claim to. Catching them means reading the policy and looking at the data: noticing that visit_ref holds unique IDs where the model says there's nothing to protect, or that the policy requires a filter the model never wrote down. That takes judgment, and judgment is now something an agent is good at.

Agents against agents

The quarterly access review was built for a world where changes were slow and only people read the data. Neither is true anymore.

So the review moves into the release process. Publisher's compliance review is an agent skill: a set of instructions an agent follows, the way a person follows a checklist. It runs in your CI/CD pipeline on every pull request that touches a model, the same way your tests do. The repository includes a recipe for setting it up. A change that breaks a control fails the check and doesn't merge, and the review's report becomes part of the audit trail.

The example also includes a data app for whoever signs off on a release. It reads which classes Publisher says each domain may return and actually returns. Then, live, it asks every domain the same question -- "how many encounters can I read?" -- once as each role, and compares every answer with what the policy says that role should get. On the correct model, every check passes.

  • 167 of 167fields have a class
  • 8 of 8gate values come from the login system
  • 5 of 5domains return only their allowed classes
  • 30 of 30access results match the policy
  • Patient chartClinician: Own hospital · 1,028Everyone else refused
  • BillingBiller: Own hospital · 1,028Everyone else refused
  • Population healthQuality analyst: All · 53,346Everyone else refused
  • ResearchResearcher: All · 53,346Everyone else refused
  • Partner data setPartner, agreement signed: All · 53,346Everyone else refused
Figure 2Every number is read live from the model Publisher is serving: what each domain declares, and thirty real queries, one per domain per role.

Load the broken billing version and the score drops to 29 of 30. The billing row is flagged "not per policy," and control MN-02 fails: the one control Publisher couldn't check on its own.

Correct model

30 of 30access results match the policy

A biller at one hospital opens billing:

1,028 encounters, their own hospital's

Matches the policy.

Variant 03: no hospital filter

29 of 30access results match the policy

A biller at one hospital opens billing:

53,346 encounters, every hospital's

Breaks MN-02: billers see only the organizations they bill for.

Figure 3A biller at one hospital could read every hospital's encounters. The model agreed with itself, and disagreed with the policy.

The compliance review runs these same checks in CI and goes further. They read the policy, pull out the list of controls, read the running model, run the automatic checks, test every domain as every role, spot-check the data wherever a class could be wrong, and write a report with a pass or fail for each control and the evidence behind it.

Agents do the work, but people still make the calls. Publisher's classification skill is the agent from earlier that proposes classes. We stripped every class from the example and ran it blind: it matched 51 of 53 classes we had assigned by hand, and it didn't guess on the other two. It turned both into questions for the policy owner.

The review has to fail safe, too. The first time we ran it in CI, Publisher refused the change, and a fresh server has no earlier model to fall back on, so there was nothing to check. The tempting fix is to report what could be checked and let the rest pass. Instead, the review fails the controls the refusal names and marks every other control "not checked." A check that can't run is not a pass.

None of this watches production and hopes to catch a problem. Before anything ships, it proves that each domain returns only what the policy allows, only to the people the policy names, and it proves it again on every change.

The evidence an auditor actually asks for

This setup produces two records that auditors ask for today and rarely get.

The first is a verdict for each control. On every pull request, the compliance review checks the model the change would serve against the policy's control register and writes a report:

  • each control in the policy
  • whether it holds
  • the evidence behind it, as counts

The report comes from the model Publisher would actually serve, not from a document about it.

Those reports answer a lot of audit requests. For SOC 2 Type II, they're evidence that access controls held across the whole audit period, one change at a time. For FedRAMP, the register and the reports together cover most of the description of how access control is implemented. For PCI, Publisher's summary of which classes each domain returns is the start of the inventory of where card data lives.

The second is a record of every query. For each query, Publisher writes one line:

  • who asked, as the login system identified them, and their verified attributes
  • which domain, which gates applied and what they decided
  • every field read, with its classes, and how many rows came back

Every major framework requires this kind of audit log (HIPAA 164.312(b), SOC 2 CC7.2, PCI 10.2, NIST AU-2), and this one is written in the policy's own terms. "A user admitted as billing read three phi.direct_identifier fields" is exactly the line an auditor would otherwise ask you to piece together.

What this doesn't prove

This only covers queries that go through the model. Anything that reaches the data another way skips every gate: a SQL client pointed straight at the warehouse, or an agent using the warehouse's own MCP server. So keep warehouse permissions tight, and make the model the only way in that you give people and agents.

It also relies on your login system to say who's asking. Publisher trusts the roles and hospitals the login system hands it, so if that system can be fooled, so can every gate. Credible runs the login layer for you in its managed platform. If you run Publisher yourself, that part is yours.

The policy is a file you own

In this example, the policy is a Markdown file in the same repository as the model. The gates and classes are annotations on the sources that enforce them. The control register is a JSON file beside both, and every review report travels with the change it reviewed. None of your compliance setup lives in a vendor's console, and none of it has to be redone when you switch warehouses, BI tools or agent platforms.

To try this yourself, start smaller than a health system:

  1. Pick one workflow.
  2. Write down ten decisions you'd let an agent make tomorrow with no person involved.
  3. For each decision, name the data it reads and that data's class.
  4. Turn each line into a numbered control that says what must be true.
  5. Put the classes and gates on the model, and let Publisher refuse anything that breaks them.
  6. Run the compliance review in your CI/CD pipeline, so every pull request is checked against the policy before it merges.

Expect step 4 to find holes in the policy itself. Writing Riverbend's policy as checks found two places where it contradicts itself. Its list of classes calls dates to the day a quasi-identifier, while its de-identification rules put date of death next to birth date as a direct identifier. And it gives "a year" as an example of data that needs no protection, while its own rule makes a birth year a quasi-identifier. Prose reads as complete until something has to run it.

Any decision whose data you can't classify yet isn't ready for an agent. That list is your work plan.

Everything in this post is open source: the policy, the model, the five broken versions, the data app and the annotations, in Malloy Publisher.

The attacker is now an agent, so the defense has to be one too.

The AI Analytics Engine

Make your data Credible

Model your data's meaning, and the AI Analytics Engine delivers answers you can trust to every agent, data app, and dashboard that asks.

Get started

More from Credible

Engineering

11 MIN READ

Materialization Is an Annotation, Not a Pipeline

Materialization in Credible is an annotation, not a pipeline. Mark a source with #@ persist or a measure with #@ preaggregate, and the engine builds the tables, routes queries to them and cleans them up. A missing table makes a query slow, not wrong, and the versioned package is what ships. This post walks through it on Malloy Publisher's storefront example, which you can run yourself.

Girish Jeswani

Girish Jeswani

Software Engineer @ Credible

Engineering

6 MIN READ

Version Control for the People Who Own the Numbers

A data model kept as code, under version control, is the right way to build one. It also left most of the business behind: first the code, then the developer tools. Coding agents and our open-source skills took care of the code. The Credible app takes care of version control, so business and technical people collaborate on the same data model in the tools they each prefer, and ship it together to the agents and apps that run on it.

Nathan Huff

Nathan Huff

Head of AI & Application Development @ Credible

Engineering

7 MIN READ

Your Vibe-Coded App Just Found Its Serving Layer

An agent can build a working data app in minutes. Running it safely in production is the hard part: login, permissions, hosting, passwords, and versions. Put the app in a Malloy package, and Credible handles all of it.

Nathan Huff

Nathan Huff

Head of AI & Application Development @ Credible