> ## Site Index
> Fetch the site index at: https://www.credibledata.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Policy Is Prose. Compliance Is Controls.

> Your auditor reads your evidence, not your policy. Put classes and gates on the data model, let Publisher refuse what breaks them, and check every change.

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](/blog/posts/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](https://github.com/malloydata/publisher), the open-source server that serves Malloy data models and enforces their access rules.

[Synthea](https://synthetichealth.github.io/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.

**Figure 1.** *A 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](/blog/posts/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:

```malloy
#(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:

```malloy
#(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](https://github.com/malloydata/publisher/blob/main/examples/clinical-records/violations/01-research-reidentifies/domains.malloy.txt))

**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](https://github.com/malloydata/publisher/blob/main/examples/clinical-records/violations/04-requester-asserted-scope/domains.malloy.txt))

**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](https://github.com/malloydata/publisher/blob/main/examples/clinical-records/violations/05-billing-reopened/domains.malloy.txt))

**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](https://github.com/malloydata/publisher/blob/main/examples/clinical-records/violations/02-mislabeled-identifier/domains.malloy.txt)), 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](https://github.com/malloydata/publisher/blob/main/examples/clinical-records/violations/03-billing-all-organizations/domains.malloy.txt)), 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](https://github.com/malloydata/publisher/blob/main/docs/compliance-review.md#in-ci). 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.

**Figure 2.** *Every 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.

**Figure 3.** *A 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](https://github.com/malloydata/publisher/blob/main/examples/clinical-records/policies/phi-access-and-minimum-necessary.md) 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](https://github.com/malloydata/publisher/blob/main/examples/clinical-records/policies/controls.json) 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](https://github.com/malloydata/publisher/tree/main/examples/clinical-records).

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