Consent reference

Config options

OptionDefaultMeaning
purposes[]Your own purposes, beside the ones composed capabilities declare. A purpose under a composed capability’s domain is refused at startup
visitorWindowDays395How many days an unmerged visitor’s grant counts, 1 to 3650. Past it, hasConsent answers no. The device-id cookie lives as long
basePath/consentWhere every route mounts, the control-plane reads included. The manifest names the mounted path, and the client projection carries it to the scaffolded screens
rateLimiterBindingAUTH_RATE_LIMITERThe rate limiting binding both writes are held to, per client IP, under a consent: key of their own

The client projection is { enabled, basePath }, and nothing else. It is declared as ConsentClientProjection in @pithy-sh/consent/src/client/projection. A Vite app reads it from virtual:pithy/consent, and a React Native app from its generated config, per environment. The purposes stay on the Worker: a screen reads them from GET {basePath}/purposes when it renders, so a purpose changes without a rebuild. A Worker composing no consent projects { enabled: false }.

Purpose fields

FieldDefaultMeaning
idrequired<domain>.<name>, lowercase, at most 128 characters. A capability declares only under its own name; yours go under your own domain
summaryrequiredOne sentence written for the person, at most 500 characters. The settings screen shows it
noticeVersionrequiredA positive integer. Bump it when the notice changes, and every older grant answers no until renewed
classes[]The data classes the purpose covers: pii, phi, or your own
explicitfalseWhether the purpose needs explicit consent, GDPR Article 9
minimumMethodclickThe weakest method a grant may be given by: click, typed, drawn, provider, weakest first
writtenWithdrawalfalseWhether a withdrawal must be at least at the grade of the grant it withdraws

email declares two: email.marketing and email.tracking, both click. A purpose id appears once in the composed catalog, and two declarations of one id are refused.

Routes

Using Consent has the calls.

RouteWhoPurpose
GET /consent/purposesanyoneEvery purpose this Worker asks consent for
GET /consent/meanyoneWhere the caller stands on each purpose, and every row behind it. No subject yet is subject: null
GET /consent/me/documents/:hashanyoneA signed document the caller’s own record names, as text/plain
POST /consent/grantsanyone · same-origin · rate-limitedGive consent to one purpose. 201
POST /consent/withdrawalsanyone · same-origin · rate-limitedWithdraw consent from one purpose. 201
GET /consent/control-plane/purposescontrol-plane · consent:readCounts per purpose and notice version, the expired grants among them
GET /consent/control-plane/expiringcontrol-plane · consent:readThe grants whose expiry has passed or falls within within days (default 30, 1 to 366), soonest first, paged by cursor and limit
GET /consent/control-plane/subjects/:kind/:subjectIdcontrol-plane · consent:readOne subject’s standing, rows and merges
GET /consent/control-plane/documents/:hashcontrol-plane · consent:readA signed document, byte for byte
GET /consent/control-plane/evidence/:hashcontrol-plane · consent:readA drawn signature or certificate, as an attachment

“Anyone” is the signed-in account, or the visitor whose device-id cookie came with the request, or a signed-out phone’s visitor. A visitor’s first grant or withdrawal mints the id and sets the cookie. Nothing else sets it. A phone has no cookie and sends its own visitor id in x-pithy-consent-visitor instead, read only from a request with no cookie at all; such a request is exempt from the same-origin gate, because nothing in it is ambient. So is a bearer request, for the same reason. A phone’s id, once merged into an account, is no visitor again. Visitors has the rest.

The control-plane reads are a management client’s, reached through the control plane. Their operations are purposes.read, expiring.read, subjects.read, documents.read and evidence.read, so the MCP server offers them as consent_* tools. consent:read is a read, and there is no control-plane write.

The grant body

FieldRequiredMeaning
purposeyesThe purpose id
noticeVersionyesThe version the person was shown. It must be current
methodyesclick, typed, drawn or provider
surfaceyesWhere the choice was made, a short lowercase name, at most 64 characters
identitynonone, session, verified_email, in_person or provider. Defaults to session for an account and none for a visitor
documentfor a signed grantThe exact rendered text, at most 256 KiB of characters
typedNamefor typedAt most 256 characters. Encrypted at rest
drawnfor drawn{ image, type }: base64, at most 512 KiB, image/png or image/svg+xml
providerfor provider{ name, envelopeId, certificate? }. The certificate is base64, at most 2 MiB
expiresAtnoAn ISO-8601 instant in the future
representativeno{ name, relationship }. The name is encrypted at rest

A withdrawal takes the same fields but noticeVersion, expiresAt and representative.

Error codes

CodeStatusMeaning
consent/unknown_purpose404No composed capability or config declares that purpose
consent/stale_notice409The grant names a notice version that is not the current one
consent/method_below_minimum400The grant’s method is below the purpose’s minimumMethod
consent/withdrawal_below_grant400The purpose requires a written withdrawal, and this one is below the grant’s method
consent/evidence_missing400The signature, the envelope or the document the method needs is missing
consent/expiry_passed400expiresAt is not in the future
consent/document_not_found404No document or evidence is kept under that hash, or the caller’s record does not name it
consent/evidence_store_missing500A drawn signature or certificate arrived, and no CONSENT_EVIDENCE bucket is bound

Bindings

BindingTypeNotes
DBd1The app database
AUTH_RATE_LIMITERratelimitAuth’s, by default. rateLimiterBinding names another
CONSENT_EVIDENCEr2Optional. Needed only for drawn and provider. pithy provision creates it

The typed and representative names are sealed under PITHY_FIELD_KEYS, which pithy secrets provision sets.

Tables

TableKeyHolds
pithy_consent_recordsUUIDOne row per grant or withdrawal: subject kind and id, purpose, action, notice version, method, surface, identity, document hash, the typed name (encrypted), the drawn image’s hash and type, the provider, envelope id and certificate hash, expiry, the representative’s name (encrypted) and relationship, and when it was recorded
pithy_consent_documentsthe SHA-256 of the textThe exact text behind a signed grant, kept once, and when it was first kept
pithy_consent_mergesUUIDOne row per visitor merged into an account: the device id, the user id, and when

All three are append-only by trigger. An update of any fact column and every delete abort. The one write allowed is the field-key rotator re-sealing an encrypted name. The tables are also declared retained, so a down migration never drops them while they hold rows.

A purge keeps them. Consent declares retain with its basis: the record is the proof of what the person agreed to and when they withdrew. An access request exports every row, the merged visitors’ rows and the merges, with the names opened. The drawn image is kept with the record, never purged with the account.

Audit actions

Recorded through audit when it is composed. Every row is one event, so the hash chain covers the record.

ActionWhen
consent/grantedA person gave consent to a purpose
consent/withdrawnA person withdrew consent from a purpose
consent/mergedA visitor’s consent was carried into the account they signed in as

A grant or withdrawal event carries the record’s id, the purpose, the notice version, the method, the surface and the document’s hash. A merge event carries the visitor’s id and the merge’s. None ever carries a name.

The core seam

NameWhereWhat it is
composedConsent(capabilities, env)@pithy-sh/core/src/consent/readerThe composed reader, or null with no consent composed
hasConsent(subject, purpose)the readertrue only for a current, unexpired, unwithdrawn grant at or above the minimum
Capability.consentPurposes@pithy-sh/coreThe purposes a capability declares, under its own name
Capability.consentWithdrawn@pithy-sh/coreCalled on the declaring capability once a withdrawal stands. Email suppresses the address for marketing here
Capability.consentGranted@pithy-sh/coreCalled once a grant stands. Email lifts the withdrawal’s own suppression here

A subject is { kind: "account" | "visitor", id }.

Peer capabilities

Required: auth. The writes wear its same-origin gate, which a bearer request and a phone’s cookie-less request pass, and its rate limiter.

Optional: email, which declares email.marketing and email.tracking and gates on them. audit, which records the three actions above in its chain. controlplane, which carries the five reads.

What is not here

No cookie banner. The settings screen is the one screen pithy ui add react --consent and pithy ui add react-native --consent write.

No e-signature provider integration. Your integration supplies the envelope and the certificate.

No refusal to write a classed field without consent. That is your call. The kit records and gates.

ESC