# Configuring the gateway

Every setting, what to put in it, and what happens if you leave it out.

Set them in a `.env` file next to `docker-compose.yml` (copy `.env.example` to
start). Running without Docker? The same names work as ordinary environment
variables, and `penname-gateway --env-file .env` reads the same file.

**Two are required: `PENNAME_API_KEY` and `PENNAME_MASTER_KEY`.** Everything
else has a working default.

## Writing the .env file

`NAME=value`, one per line. **No quotes, and no spaces around the `=`.**

```
PENNAME_MASTER_KEY=7f3a9c2e...
PENNAME_CONTROL_PLANE_URL=https://penname.io
PENNAME_DB_URL=postgresql://readonly:pass@host.docker.internal:5432/yourdb
```

Not this:

```
PENNAME_CONTROL_PLANE_URL: https://...     # that is YAML, not .env
PENNAME_CONTROL_PLANE_URL = "https://..."  # spaces and quotes both wrong
```

**You never edit `docker-compose.yml`.** A line in it like

```yaml
PENNAME_CONTROL_PLANE_URL: ${PENNAME_CONTROL_PLANE_URL:-https://penname.io}
```

only means "take this one from `.env`". Everything you configure lives in `.env`.

A `#` starts a comment. A value with a `#` in it — some passwords — does need
quoting: `PENNAME_DB_URL='postgresql://u:pa#ss@host/db'`.

---

## Where secrets live

`penname-gateway init` writes two files on purpose:

| File | Holds | Share it? |
| --- | --- | --- |
| `.env` | settings: port, provider, control plane URL | yes — paste it into a support ticket freely |
| `secrets.env` | `PENNAME_MASTER_KEY`, `PENNAME_API_KEY`, `PENNAME_DB_URL` | **never** |

`secrets.env` is readable by you only, listed in `.gitignore`, and reaches the
container through `env_file:` — not through a `${...}` variable that compose
would happily interpolate from anywhere. Keeping the three secrets in a file of
their own is what makes "send me your .env" a safe thing for support to ask.

**On a platform with a real secret store** — Kubernetes, Docker Swarm, AWS or
GCP secret managers — hand each one over as a file instead:

```
PENNAME_MASTER_KEY_FILE=/run/secrets/master_key
PENNAME_API_KEY_FILE=/run/secrets/api_key
PENNAME_DB_URL_FILE=/run/secrets/db_url
```

The gateway reads the file's contents (trailing newline ignored). Setting both
the variable and the `_FILE` to different values is refused at startup rather
than resolved one way in silence. A mounted secret never shows in
`docker inspect`, the shell history or the process list, which is where an
environment variable ends up on show.

**Running more than one gateway for the same data** — production and staging,
or several replicas — every one of them must use the **same** master key, or
the same person gets a different pen name from each. Print it from an existing
install and hand it to the next one:

```bash
penname-gateway key show                        # or: docker compose exec gateway penname-gateway key show
docker run -it -v ${PWD}:/setup pennameio/gateway init --master-key <that key>
```

What none of this does is encrypt the key at rest: the gateway has to read it
to start, so anything it could decrypt, an attacker on the same machine could
too. Disk encryption is the operating system's job — BitLocker, FileVault,
LUKS — and it is the right place for it.

---

## PENNAME_MASTER_KEY — required

The secret your pen names are derived from. Same key plus same person plus same
scope always gives the same alias, on any machine, with nothing stored.

```
PENNAME_MASTER_KEY=7f3a...   # 64 hex characters
```

Generate one, once:

```bash
openssl rand -hex 32
# no openssl (Windows)?
docker run --rm python:3.12-slim python -c "import secrets; print(secrets.token_hex(32))"
```

Treat it like a database password. Store it wherever you keep those.

- **Leave it out** and the gateway refuses to start, with a message telling you
  this.
- **Lose it** and every alias you have ever issued becomes unresolvable — you
  cannot turn `PERSON_7K3Q2M9XAB` back into a name without it.
- **Change it** and every alias changes. That is the intended way to retire a
  whole alias space, not something to do casually.
- It never leaves your machine. penname's hosted dashboard never sees it.

---

## PENNAME_DB_URL — strongly recommended

The database the gateway reads to learn which values are sensitive. Paste the
URL your application already uses.

```
PENNAME_DB_URL=postgresql://readonly:pass@host.docker.internal:5432/yourdb
```

**Use a read-only database user.** The gateway only ever runs `SELECT`, and a
read-only user is how you make that true rather than trusted.

**In Docker, `localhost` means the container, not your machine.** If your
database runs on your host, use `host.docker.internal` instead. That name
resolves on macOS and Windows automatically, and `docker-compose.yml` adds it on
Linux too, so one `.env` works everywhere.

An async driver is fine — `postgresql+asyncpg://`, `mysql+aiomysql://` and the
rest are swapped for their synchronous equivalent automatically, and the swap is
logged rather than done silently.

**Leave it out** and only emails, phone numbers and Israeli ID numbers are
protected. Those have a recognisable shape; **names do not**. Without a database
`Dana Levy` reaches the model unchanged. This is the single setting that decides
whether your customers' names are protected.

---

## PENNAME_ANTHROPIC_BASE_URL

Where `/v1/messages` is forwarded — the Claude half of the gateway.

```
PENNAME_ANTHROPIC_BASE_URL=https://api.anthropic.com/v1     # the default
```

Separate from `PENNAME_UPSTREAM_BASE_URL` on purpose, so **one gateway serves
both providers**: an app that switches from GPT to Claude changes its own
client and nothing else. Point an Anthropic SDK at `http://localhost:8088`
(no `/v1`) and it works exactly as the OpenAI SDK does at
`http://localhost:8088/v1`.

Your Anthropic key is not configured here either — the gateway forwards the
`x-api-key` header your app already sends.

---

## PENNAME_UPSTREAM_BASE_URL

The model provider you actually use. Anything OpenAI-compatible.

```
PENNAME_UPSTREAM_BASE_URL=https://api.openai.com/v1     # the default
```

Your provider API key is **not** configured here. The gateway forwards whatever
`Authorization` header your application already sends, so it never holds your
OpenAI key and never needs to.

To see what the model would receive without sending anything anywhere, point
this at `penname-echo` — see [Checking it works](#checking-it-works).

---

## PENNAME_API_KEY — required

Your penname key. Get one at <https://penname.io/signup>.

```
PENNAME_API_KEY=pk_live_...
```

**The gateway does not run without it.** It authorizes the key at startup and
before every call, and a gateway that has never been authorized proxies nothing
— it answers `402` and reports itself unhealthy. There is no setting that turns
this off; leaving `PENNAME_CONTROL_PLANE_URL` out does not turn it off either
(see below).

Two things are deliberately tolerant, because they are our problem and not
yours:

- **An outage on our side.** Once a gateway has been authorized it keeps serving
  for `PENNAME_OFFLINE_GRACE_SECONDS` (a day by default) if the control plane
  becomes unreachable, then fails closed.
- **A key over quota or suspended.** The gateway stays up and answers `402`,
  recovering by itself within a minute of the key being good again. Only a key
  the control plane does not recognise stops it at startup, with a message
  saying so.

The key is not your master key and not your OpenAI key — see
[the two keys](/docs#the-two-keys) in the README.

---

## PENNAME_CONTROL_PLANE_URL — leave it unset

Where the key is authorized, your schema is uploaded (table and column names,
never a row) and your field map comes from.

```
PENNAME_CONTROL_PLANE_URL=https://penname.io    # the default; you can omit it
```

Set it only if you run your own control plane. It is not a way to run without
one: unset means the hosted control plane, not "ask nobody".

With it reachable, the gateway uploads your schema so the dashboard is not
empty, then fetches the choices you saved and polls for changes, so a dashboard
edit takes effect without a restart. Until you have marked anything, it
reflects your schema and proposes a map itself — a reasonable start, but a
guess, not your decision.

---

## ENABLE_LOGGING — on by default

One line per request each way, plus which value became which alias.

```
ENABLE_LOGGING=true      # default; set false for silence
```

```
-> gpt-4o  protected 3 of 259 known (PERSON 1, PHONE 1, ILID 1)  4 msg  83 KB
     PERSON   D**a                  -> PERSON_BRHB6GR62Y
     PHONE    05*******9            -> PHONE_A0RVW3C5BY
<- gpt-4o  restored 3  21 in / 8 out tokens  0.4s
```

"3 of 259 known" means three values were actually swapped, out of 259 the
gateway had loaded so it could recognise them. The dictionary is the whole
address book; the first number is what left your network. Only the values that
were swapped are listed underneath.

Values are masked: enough to see the right column was picked, not enough to
identify anyone.

---

## DETAIL_LOGGING — off by default

Also prints every word the model received, as plain text, under the swap list.

```
DETAIL_LOGGING=true
```

```
-> gpt-4o  protected 2 of 259 known (EMAIL 1, PERSON 1)  3 msg  1 KB
     PERSON   D*******y              -> PERSON_2A9BS5W338
     EMAIL    d********@e**********  -> EMAIL_2YZZC836R4
   sent to the model, word for word:
     system     You are support.
                Be brief.
     user       email PERSON_2A9BS5W338 at EMAIL_2YZZC836R4
     assistant  lookup({"who": "PERSON_2A9BS5W338"})
<- gpt-4o  restored 2  9 in / 3 out tokens  0.4s
```

The swap list is your value and what the model got instead; the text under it
is the whole conversation exactly as the model read it. Only the words: no JSON,
no request settings, no tool schemas, no reply. It is the protected copy, so it
reads in aliases rather than customer data.

Still long: a real system prompt is printed in full *per request*, so left on it
fills the container's log. Turn it on to see exactly what left, then turn it off:
`DETAIL_LOGGING=false` and `docker compose up -d --force-recreate`, which also
starts the new container with an empty log. While it is on, the startup line
says `logging  on  + words sent (DETAIL_LOGGING)`.

**Use it before launch.** Turn it on, send your AI features a fake test
customer, and check that the log holds only pen names and never the test values.
That is the check in [Check it works](/docs#check-it-works-with-test-data-before-real-data),
and it should pass before any real customer goes through the gateway.

---

## LOG_VALUES — off by default

Prints the **real** value beside each alias instead of a mask.

```
LOG_VALUES=true
```

```
     PERSON   Dana Levy             -> PERSON_BRHB6GR62Y
     EMAIL    dana@example.com      -> EMAIL_W0JZFM75RM
```

**Your log then contains customer data.** Logs get shipped to log services,
attached to support tickets, and pasted into chats — that is how this data
escapes, far more often than through the model. The gateway prints a warning at
startup while it is on.

Turn it on to diagnose a specific mapping, then turn it off.

---

## PENNAME_PORT

Which port on **your machine** publishes the gateway. Docker only.

```
PENNAME_PORT=8088     # the default
```

Change it only if something else already has 8088 — you will know, because
`docker compose up` fails saying the port is in use. The container keeps 8088
internally either way; only the published port moves.

The gateway is published on `127.0.0.1` only. It holds your master key and reads
your database, so it has no business being reachable from the internet.

---

## The scope header

**A call with no scope header protects no names.** Not fewer names — none.
Emails, phones and ID numbers still go, because those have a shape a detector
can find; a name does not, so the only way to protect one is to know whose row
it is. The count in the log (`protected 1 of 1`) counts what *was* replaced and
cannot see what it never knew about, which is why an install can look like it
is working long before it protects anything.

Since 0.3.0 the gateway says so out loud:

- the first such call logs `NO X-Penname-Scope HEADER — names from your
  database were NOT protected in this call`,
- every such call is marked `NO-SCOPE(names unprotected)` on its log line,
- and the count reaches your dashboard, so you can see the share.

To refuse those calls instead of reporting them, set `PENNAME_REQUIRE_SCOPE=true`
(below). Every deployment handling real data should.

```
X-Penname-Scope: tenant=45; business=89; engagement=E-2026-17; user=123
```

This is how the gateway knows which rows to read. Emails, phone numbers and ID
numbers have a shape a detector recognises without it; **names do not**, so
without this header names go to the model in the clear.

**Do not guess the key names.** Start the gateway and ask it:

```bash
curl -s localhost:8088/scope
```

It reads them off your own schema, and the same list is printed at startup:

```
scope keys (outermost first): tenant, business, engagement, user
```

Framework noise in a table name is stripped for you: `auth_user` answers to
`user`, `tbl_customer` to `customer`, `crm_contact` to `contact`. The response
also carries a `database` section — how many tables, whether relationships are
declared, what kind of keys — so you can see what the gateway understood about
your schema before trusting it.

Every table is filtered by the kind of id it actually holds — `businesses` by
`tenant_id`, `memberships` by `business_id`, `evidence` by `engagement_id`. A
table your request says nothing about is skipped, not queried with an id that
belongs to something else.

You do not have to send every key. The gateway follows your foreign keys
outwards, up to two tables, so `tenant=45` on its own still reaches `evidence`
through `engagements` and `businesses`. Send more ids when you have them and the
reads get narrower and cheaper; send fewer and the coverage stays.

`owner=<user id>` keeps working and keeps meaning what it always meant.

### What happens when the scope is wrong

| | |
| --- | --- |
| A key no table uses | ignored; the log warns `scope … matched no table` |
| A key that fits some tables and not others | those tables are read, the rest are skipped |
| A text id where the column holds integers | that path is skipped, not offered to the database. `engagement=E-2026-17` is never sent to a `user_id` column |
| No header at all | detectors only — shapes protected, names not |

### PENNAME_REQUIRE_SCOPE — off by default

```
PENNAME_REQUIRE_SCOPE=true
```

Refuse the call, `422`, when the scope header matches nothing in the field map.
Without it such a call still goes to the model, protected by detectors alone —
and from the outside that looks exactly like a call that was fully protected.
Turn it on once your app is sending the header, and a regression that drops it
becomes an error you can see instead of a leak you cannot.

```
PENNAME_REQUIRED_SCOPE_KEYS=tenant,engagement
```

Stricter: name the keys this deployment is never protectable without. A request
missing one is refused with `Missing engagement scope — protection cannot be
guaranteed`, whatever else it carried.

### PENNAME_SCOPE_NAMESPACE — the outermost key by default

Aliases are namespaced, so the same person is the same pen name across calls.
That namespace has to stay still: if it were "every key in the header", a
request that happened to mention an engagement would give a person a different
alias from the request before it, and a conversation would stop restoring
halfway through. So one key names the namespace — by default the outermost one
the schema knows about, which is the one every request carries.

Set it explicitly if you would rather pin it (`PENNAME_SCOPE_NAMESPACE=tenant`),
or to `*` for the old behaviour of using every key sent.

---

### PENNAME_DB_URL and MongoDB

The URL's scheme picks the reader. Anything SQLAlchemy speaks is relational —
PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, Oracle — with async schemes
rewritten to their sync driver automatically. `mongodb://` (and
`mongodb+srv://`) selects the document reader:

```
PENNAME_DB_URL=mongodb://readonly:pw@localhost:27017/mydb
```

The URL must name the database. Collections are read as tables and top-level
fields as columns, with the shape learned by sampling documents (a field must
appear in the sample to be mappable). `userId`-style fields resolve to their
collection the way `user_id` columns do; `owner_id` resolves to the people
collection by convention. Everything above — the ownership axis, catalogues,
membership collections, the field map from the dashboard — works identically;
joins run as short chains of queries. Not yet read: fields nested inside
sub-documents, and arrays.

---

### PENNAME_MAX_JOINS — 2 by default

How far the gateway will join outwards to reach a scope column. Two hops
covers data -> owner -> organisation. A schema whose axis is genuinely deeper
raises it; the price is per-request join depth, so it is a choice, not a
default.

### Polymorphic ownership — handled, guarded

`owner_id` next to `owner_type` (Rails, Laravel) means the SAME id column
points at different tables row by row — and `owner_id=55` with
`owner_type='Organization'` is not user 55. The pair never produces an
unguarded read: each owner-capable table gets its own scope path with the
type column bound into the query (`"User"`, `"user"`, `"users"` — the data
decides the spelling), and the audit line on every load names the guard.

---

### PENNAME_SCOPE_TIERS / PENNAME_SCOPE_IGNORE — usually not needed

The gateway reads the schema once for its shape: who the people are
(`users`, `accounts`, whatever it is called here), what they can belong to
(tenants, organisations, teams — proven by NOT NULL links and membership
tables, however many levels deep), and which referenced tables are lookup
lists that merely describe them. A lookup list — `plans`, `roles`,
`currencies` — is never a scope key: narrowing by one would read other
customers' rows wholesale. `GET /scope` shows every conclusion with its
reason, in the `ownership` block.

Two facts no schema can state are yours to override, comma-separated table or
key names:

```
PENNAME_SCOPE_TIERS=project        # "projects" IS a tenancy boundary here
PENNAME_SCOPE_IGNORE=regions       # "regions" is a label, not a boundary
```

---

## PENNAME_ALIAS_STYLE — typed by default

What an alias looks like on the wire.

```
PENNAME_ALIAS_STYLE=typed     # PERSON_7K3Q2M9XAB, PHONE_A0RVW3C5BY   (default)
PENNAME_ALIAS_STYLE=neutral   # REF_7K3Q2M9XAB,   REF_A0RVW3C5BY
```

Typed aliases tell the model what each token IS — it can put the phone number
in the phone field of a tool call and address the person by role. That is also
their cost: the provider learns that your prompts *contain* phone numbers,
names, bank accounts — the categories, never the values.

`neutral` removes even that. Every alias reads `REF_<tag>`; nothing in the
payload says which was a name and which was an ID number. The trade is answer
quality on tasks where the type mattered — the model sees three identical-
looking tokens and has to work out from context which one to call.

The tag itself is identical in both styles (the kind is folded into the HMAC
either way), so you can flip this setting without re-keying anything, and
replies keep restoring. A prefix of some kind has to stay — restore has to find
these strings again in whatever the model writes back.

---

## Keywords — protect what no column holds

A paragraph column can carry a name that never appears as a structured value —
"Met Kaplan Holdings' CFO", a project codename, a phrase that must not leave.
The field map cannot reach inside prose, and a name has no shape a detector
recognises. Keywords are you saying it directly: this string, wherever it
appears, gets this treatment.

Set them in the dashboard (**Field map → Keywords**) — they reach the gateway
with the next map poll, no restart — or pin them locally:

```
PENNAME_TERMS_FILE=/path/keywords.json
```

```json
{"ORG": ["Kaplan Holdings"], "PERSON": ["Yosef Mizrahi"], "SECRET": ["Project Bluebird"]}
```

The kind decides the treatment: **SECRET** refuses any call carrying the value;
**NONE** exempts it from the detectors (a support address that looks like an
email, because it is one); anything else aliases it wherever it appears —
inside paragraphs included — with the reply restored as usual. Keywords apply
to every request, scope header or not, and the alias derives from the value
itself, so it is stable with nothing stored. File and dashboard keywords are
additive; a dashboard edit never erases a locally pinned term.

For names nobody typed anywhere — new people appearing in free text — see
`PENNAME_NER`: best-effort statistical detection, never a guarantee.

---

## What SECRET does — read this before marking a column SECRET

SECRET is not “protect this harder”. It means **this value must never reach a
model at all**, and what happens then depends on `PENNAME_SECRET_MODE`:

| Mode | What happens to the call | Default |
| --- | --- | --- |
| `redact` | the value is **cut out** of the prompt and the call goes on. The model sees `[secret removed by penname]` | **yes** |
| `block` | the whole call is **refused** with a 422 | no |
| `warn` | the value is sent anyway and logged loudly | no |

So by default a SECRET column does not stop your app — it silently removes
that value from the prompt. **If your product legitimately hands that value to
a model, mark the column something else.** A signing link is the example that
cost a customer an afternoon: penname proposes SECRET for `signing_token`
(anyone holding it can sign), and an assistant whose job is to send signing
links then finds the link deleted from its prompt, with no error to explain it.

Values shorter than 8 characters can never block or be redacted — a column
named `password_reset_channel` holding `sms` is reported at startup as a weak
secret instead, because blocking the word “sms” would take the product down.

---

## PENNAME_SCHEMA_ONLY -- off by default

Read the column names, never the rows.

```
PENNAME_SCHEMA_ONLY=true
```

The gateway still reflects your schema, so the dashboard works and you can mark
columns sensitive -- but it never runs a `SELECT` for data. Emails, phone
numbers and ID numbers stay protected, because a detector recognises them by
shape in the prompt itself.

**Names do not.** A name is ordinary text; the only way to know a given string
is a customer's name is to have read it from your customer table. Turn this on
and names reach the model unchanged. The gateway prints a notice saying so at
every startup, so nobody inherits this setting without knowing what it costs.

Worth being clear about what this does and does not change: the gateway is a
proxy, so it holds your prompt in memory either way. This setting stops it
reading your database, not reading your traffic. What it never affects is the
hosted dashboard, which receives schema and token counts and never content.

---

## Everything else

Working defaults; most people never set these.

| Setting | Default | What it does |
| --- | --- | --- |
| `PENNAME_MAP_POLL_SECONDS` | `10` | How often to ask the dashboard whether the field map changed. One integer per ask, so this is cheap. Floor of 5. |
| `PENNAME_MAP_REFRESH_SECONDS` | `300` | Full re-fetch of the map even when the version looks unchanged, so a missed edit heals itself. |
| `PENNAME_MAP_FILE` | none | Read the field map from a local JSON file instead of the dashboard — for pinning the map in version control, or keeping the last-known one when the dashboard is unreachable. It is not a way to run without a key. |
| `PENNAME_TERMS_FILE` | none | Keywords from a local JSON file (`{"ORG": ["Kaplan Holdings"]}` or `[{"kind","value"}]`), additive to the dashboard's. See **Keywords** below. |
| `PENNAME_SCOPE_HEADER` | `X-Penname-Scope` | The header your app sends to say whose data a request is about. Rename it only if it collides with something. |
| `PENNAME_REQUIRE_SCOPE` | off | Refuse a call whose scope header matches nothing in the field map, instead of sending it with only the detectors. See below. |
| `PENNAME_REQUIRED_SCOPE_KEYS` | none | Comma-separated keys this deployment cannot be protected without, e.g. `tenant,engagement`. A request missing one is refused. |
| `PENNAME_SCOPE_NAMESPACE` | outermost key | Which scope key names the aliases. Set it to pin one (`tenant`), or to `*` for the pre-scope-keys behaviour of using every key in the header. |
| `PENNAME_CUSTOMER_ID` | `customer` | Labels this gateway in its own logs. Cosmetic. |
| `PENNAME_NER` | off | Best-effort machine detection of names the field map missed. Needs the `ner` extra (large: transformers + torch). A guess, never a substitute for the map. |
| `PENNAME_NER_MODEL` | built-in | Which NER model to load, when `PENNAME_NER` is on. |
| `PENNAME_SECRET_MODE` | `redact` | What a SECRET value in a prompt does: **`redact`** cuts it out and sends the rest (never stops your app, never leaks it), `block` refuses the call, `warn` sends it anyway and logs. |
| `PENNAME_ANTHROPIC_BASE_URL` | `https://api.anthropic.com/v1` | Where `/v1/messages` is forwarded, so one gateway serves both providers. |
| `PENNAME_REPORT_USAGE` | on | Send token **counts** (never content) to the control plane, so your Usage page can show your own spend. Set `false` to send nothing — it silences the usage page, it does not turn the licence check off. |
| `PENNAME_OFFLINE_GRACE_SECONDS` | `86400` | How long an already-authorized gateway keeps serving when the control plane is unreachable. A day, so an outage on our side never stops your product. Before the first successful authorization there is no grace. |

---

## Checking it works

```bash
curl http://localhost:8088/healthz
```

`"tables"` is the number that matters: non-zero means it read your schema and
worked out which columns hold customer data. Zero means it found no table it
could scope to a user — the field map will fix that.

Then send one real request and read the log. `protected 3 of 259 known
(PERSON 1, PHONE 1, ILID 1)` tells you it worked; `protected 0` tells you it did
not. The second number is the dictionary size, not what was sent.

To see what the model *would* have received without sending anything at all,
point the gateway at the bundled fake provider:

```bash
penname-echo --port 9099
# then, in the gateway's .env:
PENNAME_UPSTREAM_BASE_URL=http://host.docker.internal:9099/v1
```

`penname-echo` prints every message it receives and forwards nothing. With
`--expect "Some Real Name"` it becomes a pass/fail check: it reports any value
that reaches it that should not have.
