> ## Documentation Index
> Fetch the complete documentation index at: https://midplane.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Column masking

> Redact sensitive column values in query results — per column, on top of read access, and fail-closed.

An agent allowed to read a table reads every value in it — including the personal data you'd never paste into a chat: emails, dates of birth, salaries, card numbers. Column masking lets the agent keep querying — listing customers, joining orders, analyzing trends — while those *values* come back redacted (`0.12.0+`). It's per-column and layered on [`table_access`](/docs/concepts/table-access): a masked column must still be readable, and masking only shapes a value the agent is already allowed to read — it never grants access.

The engine masks **at the source** — it rewrites the query so the real value is replaced before the query filters, joins, aggregates, or returns. It resolves each field back to its base column, so an alias, a join, or a bare `SELECT *` can't dodge a mask.

## Transforms

Each mask names a transform. Three are presets (a bare string); four are parametric (a `t:` map with options). Every transform except `noise` is deterministic — the same input always yields the same output, so masked columns stay join- and group-safe.

| Transform         | What it does                                                              | Applies to                 | Example                                              |
| ----------------- | ------------------------------------------------------------------------- | -------------------------- | ---------------------------------------------------- |
| `full-redact`     | Replaces the value with `***`.                                            | Any type (output is text)  | `ada@acme.io` → `***`                                |
| `null-out`        | Replaces the value with a typed SQL `NULL`.                               | Any type (type-preserving) | `12345` → `null`                                     |
| `consistent-hash` | `HMAC-SHA256(salt, value)`, first 16 hex — a stable, join-safe pseudonym. | Any scalar                 | `ada@acme.io` → `a1b2c3…`                            |
| `partial`         | Reveals leading/trailing characters, masks the rest.                      | `TEXT`                     | `{keepEnd: 4}` on a card → `••••••••••••4242`        |
| `generalize`      | Truncates a date, or rounds a number down to a bucket.                    | Dates or numbers           | `{granularity: year}` on `1994-07-23` → `1994-01-01` |
| `pseudonymize`    | A deterministic realistic fake from a built-in dictionary.                | `TEXT`                     | `{kind: name}` → a stable fake name                  |
| `noise`           | Multiplies a number by a random factor within ±`ratio`.                   | Numbers                    | `{ratio: 0.1}` on `50000` → `[45000, 55000]`         |

The four parametric transforms take options:

<ParamField path="partial" type="{ keepStart?, keepEnd?, glyph? }">
  Reveal `keepStart` leading and `keepEnd` trailing characters (sum ≤ 64), masking the rest with `glyph` (default `•`).
</ParamField>

<ParamField path="generalize" type="{ granularity }">
  `year` / `month` / `day` truncates a date (UTC); a positive integer rounds a number **down** to that bucket width.
</ParamField>

<ParamField path="pseudonymize" type="{ kind }">
  `email`, `name`, or `phone` — the fake keeps the shape of a real value of that kind.
</ParamField>

<ParamField path="noise" type="{ ratio }">
  Proportional spread in `(0, 1]`; `0.1` lands within ±10% of the input.
</ParamField>

<Warning>
  `noise` breaks joins and grouping by design — use it for rough magnitude in analytics, never on a key.
</Warning>

## Add a mask

<Tabs>
  <Tab title="Cloud (dashboard)">
    A project's **Exposure** scan reads only your database's `information_schema` — column names and types, **never row data** — and flags likely-PII columns with a suggested, type-valid transform you apply in one click. The **masked preview** runs a read through the same `query` path your agent uses and shows exactly what the agent gets back, so you can prove a mask before you rely on it. Saving a mask triggers a brief reconnect, and every mask change is recorded in the [audit log](/docs/concepts/audit-trail).
  </Tab>

  <Tab title="Self-host (YAML)">
    Declare masks in the `column_masks` block of your policy, shaped `"schema.table"` → (column → transform) — a preset as a bare string, a parametric transform as a `t:` map:

    ```yaml theme={null}
    column_masks:
      public.users:
        email: full-redact
        ssn:
          t: partial
          keepEnd: 4
        dob:
          t: generalize
          granularity: year
    ```

    Masking needs a per-deployment salt: set `MIDPLANE_MASK_SALT` when you run the engine directly, or `MIDPLANE_MASK_SALT_MASTER` under a [control plane](/docs/self-host/deploy#configuration) (it derives a per-project salt). The engine **refuses to boot** a database that declares masks without it, and `column_masks` is read at boot, not hot-reloaded. See the [policy schema](/docs/reference/policy-schema) for the exact field shapes.
  </Tab>
</Tabs>

## Good to know

Masking is **fail-closed**: a query shape the engine can't prove safe to rewrite — a view over a masked table, a whole-row serialization like `to_jsonb(users)`, an opaque function — is denied whole (a [`column_masking`](/docs/reference/denial-reasons) reject), never partially masked. And a filter or sort sees the **masked** value, so the agent can't infer its way to the real one — `WHERE credit_card = '4111…'` matches nothing once the card is masked. But an order- or format-preserving transform like `generalize` can still leak coarse buckets through a range filter. **If a column is a secret the agent must never act on, deny the table rather than mask it.**

## Next steps

<Columns cols={2}>
  <Card title="Write a policy" icon="shield-check" href="/docs/policies/overview" horizontal>
    Grant per-table reads and writes, then layer masks on top.
  </Card>

  <Card title="Denial reasons" icon="ban" href="/docs/reference/denial-reasons" horizontal>
    The shapes masking denies, and how to rework a query.
  </Card>
</Columns>
