A field declares its own privacy class, in its schema, and the declaration drives everything downstream. The compiler refuses the value on a log line, an audit record and an error. redact gives you the masked view instead. And your control plane reports which schemas and fields hold what, derived from the code that is running. pithy privacy is the command.
Mark a field
import { phi, pii } from "@pithy-sh/core/src/privacy/classes";
import { z } from "zod";
export const Patient = z
.object({
id: z.string().describe("The patient's stable id."),
email: pii(z.string().describe("The address reminders are sent to.")),
diagnosis: phi(z.string().describe("The recorded condition.")),
})
.describe("A patient.");
export type Patient = z.output<typeof Patient>;A marker does two things in one call. It attaches .meta({ privacy: "pii" }), which redact, classifyPrivacy and the inventory read. And it applies a Zod brand under the key privacy:pii, which changes the inferred type. The brand is what stops the log call from compiling.
The brand is on the field, not the object, so extracting the field does not launder it. user.email is as refused as user. The key is namespaced, so a brand of your own is never read as a privacy class.
Nothing at runtime changes. Patient.parse(row) returns the data it did before the marker, and no column is added. The input side stays unbranded: the SQLite row, the Kysely table type and a KV write are what they were. The one cost is that a value of the app type must come from a parse, so a .default() goes inside the marker: pii(z.string().default("guest")).
A marker composes with every codec. pii(SQLiteDate), phi(SQLiteBoolean) and a marked field inside sqliteJson(Profile) each encode to the row shape and parse back to the unmarked schema’s data, proved against a real D1. A marker is read through .optional(), .nullable(), .default() and the other wrappers that are the same field. Two markers on one field give it both classes.
The classes
| Class | What it is | Masks to |
|---|---|---|
secret | A credential or a bearer artifact: a session id, a magic-link token, a receipt, a key. The harm is impersonation, not exposure | [secret] |
pii | Personal data under GDPR or CCPA: an email address, a name, a phone number, a postal address | [pii] |
phi | Protected Health Information under HIPAA: a diagnosis, a treatment, a result | [phi] |
pci | Cardholder data under PCI-DSS: a primary account number | First six and last four, 424242******4242 |
The set is open. definePrivacyClass({ name: "genetic", mask }) registers a class of your own and returns its marker, usable exactly like the four. A name is a lowercase identifier. A duplicate, a built-in’s name included, is refused at registration, because two modules each believing they own genetic would disagree about its mask.
pci is for your own schemas. Every payment rail Pithy ships is hosted, so no card number reaches your Worker through the kit.
secret is not @pithy-sh/secrets. That package decides where a credential your Worker reads is stored. secret() says what a field in a data shape is: a receipt, a token, a key hash.
What the compiler refuses
The logger’s message, fields and child names, the metadata of an audit event, and the message, action and detail of every PithyError refuse a marked value at compile time. That holds on every package’s own error vehicle too.
log.info("signup", { user }); // refused: a row with a marked field
log.info("signup", { email: user.email }); // refused: the branded string itself
log.info("signup", { id: user.id }); // compiles: unmarked
log.info("signup", { user: redact(Patient, user) }); // compiles: { id: "u_1", email: "[pii]", diagnosis: "[phi]" }
new InternalError({ detail: user.email }); // refusedIt checks a marked value anywhere a field value goes, a record keyed by a marked value, and a marked value nested inside objects and arrays down to six levels below the field.
What it cannot see. A template literal is a string to TypeScript whatever was interpolated, so `signup for ${user.email}` compiles: interpolate the id, or the redacted value. Nothing deeper than six levels is checked, and neither is a cast or an unknown.
None of it is checked at runtime. A string that escaped the type is an ordinary string. HTTP responses are not gated at all, because returning a user their own email is correct.
The masked view
redact(Schema, value) returns a copy in which every unmarked field is verbatim and every marked one is its class’s mask. It reads what Schema.parse(row) returns and never mutates its argument. Redacted<typeof Schema> is the type of the result.
It is exact for the shapes a schema is ordinarily built from: objects and their pick, omit, partial and extend, arrays, tuples, records, maps and sets, the wrappers, unions and intersections, the kit’s own codecs, and recursion through z.lazy or a getter to 256 levels. Outside that, it masks whole and never leaks. A transform, a pipe, a foreign codec, z.custom or unknown with anything marked beneath it is masked with the strongest class it carries. Past the depth limit, or past the call’s budget of 10,000 schemas, a value is masked [truncated]. It never throws and never returns a raw marked value. The full contract, case by case, is docs/PRIVACY.md in the repository.
The mask is a placeholder, not a hash. [pii] loses correlation: two lines about one person look unrelated. An email is low-entropy enough to brute-force from an unkeyed hash. Correlation is what the unmarked id beside it is for.
classifyPrivacy(Schema) lists every position redact masks and its class: email, address.line1, devices[].token. hasPrivacy(Schema, "phi") is the cheap boolean a route or a guard branches on.
Declare what the project handles
// pithy.config.ts, at the root
const config = {
name: "acme",
privacy: { classes: ["pii", "phi"] },
};Root rather than per Worker, for the reason seed.productionEnvironments is root: a project’s compliance posture is not something one Worker gets to have and another does not. pithy privacy enable --class pii --class phi writes it.
It is a declaration, not an allowlist. Marking phi() in a project that declared only pii compiles, deploys, and is reported as observed but not declared. What the declaration does is close console, and turn on the reporting that sets declared beside observed.
The console gate
A structured log goes through the logger, where the brand stops a marked value. A console.log goes around it. So once a class is declared, console is closed three ways:
- The rule.
pithy privacy enablewrites Biome’ssuspicious/noConsoleintobiome.jsoncand Oxlint’sno-consoleinto.oxlintrc.json— into each one the project holds — aterror, on each Worker’s source in every language they lint, and not on tests. - The deploy.
pithy deployrefuses a project that declared a class while the rule is off, naming the file, the rule and the fix. This is the only hard failure the privacy work adds. - The report. The manifest carries the gate’s state, so a project that deleted the rule and shipped around the CLI is visible.
The gate reads one of three states, because Pithy does not own your toolchain. It reads Biome’s config and Oxlint’s alike, and with both configured it is enforced only when both close the rule: one off makes the gate off, and one unverifiable keeps it from enforced.
enforced. Every linter config was read and the rule iserror, allowing no method, for every Worker source file.off. For some Worker source file the rule is noterror: unstated,warn, lowered by an override, letting a method through, or the file left unlinted — by a disabled linter or Biome’s includes, or by Oxlint’signorePatternsand a root.eslintignore. Deploy refuses.unverifiable. No linter config Pithy can read: nobiome.json,biome.jsoncor.oxlintrc.jsonat the root, one that does not parse, bothbiome.jsonandbiome.jsonc, a nested config, a glob Pithy will not guess its linter’s reading of — a!in Oxlint’sfilesamong them — or one thatextendsanother, whose overrides could still turn the rule down. Deploy proceeds, says so on every run, and the manifest carriesunverifiablewith the reason.
unverifiable is honest, and it is not a hiding place. Deleting biome.jsonc or .oxlintrc.json to escape a failing gate moves a project from enforced to a state the dashboard renders as unverified, on every read — or, with the other config still there, to whatever that one reads. You own both files, so Pithy cannot make the rule impossible to turn off. What it makes impossible to turn off is the visibility. The one state Pithy never reports is a false enforced.
A project that declares no class deploys whatever the rule says.
What the control plane reports
The manifest carries a privacy block behind manifest:read, because it names classes and counts and never a field:
| Key | Meaning |
|---|---|
declared | The classes the project declared, as attested by the last pithy deploy. Empty when it declared none; null when no attestation reached this Worker |
observed | Every class with at least one marked field in this Worker’s storage schemas, derived at runtime |
counts | Marked fields per observed class |
observedNotDeclared | Classes the code holds that the project never declared: a posture change nobody escalated |
declaredNotObserved | Classes declared with no marked field behind them: an unfinished migration |
consoleGate | enforced, unverifiable, not_required (no class declared), or unattested |
consoleGateReason | Why the gate is unverifiable |
attestedAt | When the attestation was written |
sources | The storage walked: d1 tables and kv stores. Anything not listed was not walked |
GET /control-plane/privacy, behind its own privacy:read scope, returns the same posture and the full inventory: every schema holding a marked field, each field’s path and class, and where it lives.
Each field record also says whether it is encrypted at rest, and the posture carries the field key’s retirement guard. Encrypted fields has both.
The inventory is derived from the running code, not stamped at build. The schemas are already in the bundle, because they are the table definitions. It walks once per isolate, on the first control-plane read, in single-digit milliseconds for the whole kit. A developer who unmarks a PHI field cannot suppress the consequence: the field leaves the report on the next deploy.
It walks D1 tables and KV stores, the key, value and metadata of each, because that is where persisted data lives. R2 objects, Vectorize metadata, Durable Object storage, email templates and request bodies are not walked, and sources says so.
Declared is an attestation
The declared classes and the gate’s verdict reach the Worker as the PITHY_PRIVACY var, which pithy deploy passes to wrangler. That is an attestation, dated to the last deploy Pithy performed. The observed inventory is the derivation, and is not editable. An attestation that disagrees with what the running code holds is itself the finding.
A raw wrangler deploy uploads only the vars its config names, so a Worker shipped around pithy deploy reports unattested rather than whatever was last true. Under keep_vars: true the var survives and describes an older build, so the manifest carries attestedAt beside versionCreatedAt, the version’s own creation time. A version created long after its attestation is a build the attestation does not describe.
Find what is not marked
pithy privacy scan walks the same storage schemas, with the same walk, and reads each field’s name. email, phone, dateOfBirth, ssn, diagnosis and the rest are candidates, each with the class it suggests and the marker to apply. It exits 0, edits nothing, and fails no build. Which of your fields are regulated is a question for your jurisdiction, your sector and your counsel. Pithy surfaces candidates, and the marking stays yours.
What this does not do
- Record who read what. The access trail HIPAA §164.312(b) asks for is later work. This ships the declaration it will read.
- Encrypt anything by itself. A class is a claim about what a field is, not how it rests, and it changes no stored value. Encryption is the separate, opt-in claim about how a field rests: Encrypted fields.
- Decide what you must mark. No gate fires on an unmarked field.
- Require audit. Declaring a class offers it, at a terminal. It never demands it.
- Mark every kit schema yet. This ships the mechanism, and each capability adopts it in turn. Auth was the first: an account’s
nameandemailarepii, so a Worker that composes auth observespii, and reports it underobservedNotDeclareduntil the project declares it. Email marks every recipient address it storespii, a job’stoAddressandrecipientKey, a suppression’semailand an event’srecipient, so a Worker that composes email alone observespiitoo.pithy privacy scanreports a kit field no capability has marked yet as the kit’s to mark. The record of processing reads these markers, so a field left unmarked is one it cannot cross-check. - Erasure or subject-access requests. A class drives neither. Deleting an account, its purge window and its export are auth’s account lifecycle, which works from each capability’s purge contribution, not from how a field is marked. A class drives one thing about retention: the
retentionblock’sclassAgesdeletes an account inactive past the strictest age among the classes held by the schemas that keep rows about a user (retention and legal holds).