Skip to content

Privacy & Pseudonymity

Atelier treats privacy the way it treats everything else: as a declaration. A field that holds personal data says so in the sheet; an entity whose rows belong to a citizen declares how; an entity that stores pseudonyms instead of identities carries the marker. The machinery — the per-tenant data inventory, the erasure workflow, the re-identification guard — derives from those declarations, so a new vertical gets the whole privacy posture by writing schema, never by wiring code.

Personal data is declared

A field holding personal data carries a pii: classification on its declaration:

yaml
fields:
  - field_key: reporter_email
    field_type: { type: string }
    pii: contact          # contact | identity | quasi | none
  • contact — communication channels: emails, phones, addresses.
  • identity — direct identity: person names.
  • quasi — indirect identifiers: birthdates, account ids.
  • none — reviewed and not personal (silences the naming heuristic; recorded so the review isn't repeated).

The classification lives on the live schema, so everything downstream reads it at runtime: the diagnostics inventory lists every entity's declared personal data per tenant (your processing-record view), and a field whose name looks personal but carries no classification is flagged at import — the next contact_email authored bare gets caught before it ships.

Erasure derives from the declarations

An entity whose rows belong to a citizen declares the join once:

yaml
schema:
  metadata:
    pii_subject: { field: reported_by }   # the field holds the account id
    # or
    pii_subject: { rel: reserved_by }     # the relationship points at the user

When a citizen requests erasure, the platform workflow walks every entity that declares both a classification and a subject join, and scrubs the classified fields on the citizen's rows. Declaring a new PII field in any sheet extends erasure automatically — no workflow edits, no per-vertical scrub list.

No pii_subject means the entity's personal data belongs to a third party — an event organizer, an investor lead — and a citizen's erasure deliberately never touches it. Absence is a statement, not an omission.

Pseudonymity is declared, not folklore

Some records must be countable but never attributable — votes are the canonical case. Atelier's pseudonymization primitive is one platform function, adopted per entity by declaration:

yaml
schema:
  metadata:
    pseudonymized: true          # the re-identification guard derives from this
  fields:
    - field_key: voter_pseudonym
      field_type: { type: string }
      required: true
      indexed: true
  unique_constraints:
    vote_edition_voter_pseudonym_unique: [edition, voter_pseudonym]

The pattern has four parts, and every one matters:

  1. The pseudonym is computed, never stored alongside the identity. The write action fills the field with $function.caller_pseudonym, which computes HMAC-SHA256(scope key, account id) — deterministic for the same person in the same scope, unlinkable across scopes. Anonymous callers are refused before any key is touched.
  2. The scope key is per-tenant, per-scope, and held in the secret store. A scope is the unit of unlinkability — for participatory budgeting it's the edition. The key is created on first use and never leaves the vault; the same citizen gets different pseudonyms in different editions, so activity can't be joined across scopes.
  3. The unique constraint enforces one-per-person without knowing who. UNIQUE(scope, pseudonym) is how "one vote per citizen per edition" holds while the row stores no identity at all. Determinism within the scope is what makes the constraint work.
  4. Destroying the key is erasure. Closing a scope ($function.destroy_scope_key) hard-deletes the HMAC key. Every pseudonym in the scope becomes permanently unverifiable — aggregates survive, re-identification becomes impossible. Because the key is shared by everyone in the scope, crypto-erasure is a scope lifecycle action, not a per-citizen one; per-citizen erasure is handled by the classified-field scrub above, which leaves pseudonymous rows untouched (they carry nothing to scrub).

The pseudonymized: true marker is what the runtime derives the re-identification guard from: any audience or eligibility rule that tries to walk through a pseudonymized entity back to identities is refused at compile time, fail-closed. Declare the marker and the guard exists — no entity is named in code, and a fetch failure refuses rather than silently dropping the protection.

Adopting the pattern in a new vertical (petition signatures, survey responses) is therefore four declarations in one schema block: the pseudonym field written by $function.caller_pseudonym with a scope reference, the UNIQUE(scope, pseudonym) constraint, metadata: pseudonymized: true, and a scope-close action carrying $function.destroy_scope_key. Nothing else — the guard, the refusal posture, and the crypto-erasure semantics ride the declarations.

Where an outbound lane must only reach consenting citizens — mass communication is the canonical case — the requirement is declared on the delivery rule (required_consent_purpose), and the compiler adds the consent condition to every audience it resolves. An operator authoring a segment cannot omit the gate, because the gate isn't part of the segment: it's part of the lane. Previews resolve through the same compiler, so the audience you preview is the audience the send reaches.

What this adds up to

ConcernDeclarationWhat derives from it
Knowing what personal data existspii: on the fieldPer-tenant inventory, bare-field warning at import
Erasing a citizen's datametadata.pii_subjectThe erasure workflow's scrub scope
Unattributable participationmetadata.pseudonymized + the four-part patternRe-identification refusal, scope crypto-erasure
Consent-gated outreachrequired_consent_purpose on the ruleThe consent condition in every resolved audience

Retention windows are deliberately absent: no tenant policy has asked for them, and Atelier doesn't build privacy machinery "just in case." When a real policy arrives, a declared per-entity retention window enforced by a scheduled action rides the same lanes.

Atelier — declare your application, generate the product.