diff --git a/.husky/pre-commit b/.husky/pre-commit index ca1c0c7..5529e6e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -19,6 +19,29 @@ else fi # Format/lint only the staged files. Skipped cleanly before the first install. +# +# THE RUNNER IS RESOLVED RATHER THAN ASSUMED, and that is a correction. +# +# This read `pnpm exec lint-staged`. On a machine without pnpm the hook exited +# 127 before running anything, so `git commit` failed for a missing package +# manager rather than for a problem with the commit. The predictable response is +# `--no-verify`, which skips the SECRET SCAN as well, and on a public repository +# that is the expensive half. A gate that fails for the wrong reason does not +# get fixed; it gets bypassed, and it takes the rest of the gate with it. +# +# So the runner degrades the way the scanner above already does. `lint-staged` +# lives in node_modules either way, so this runs exactly the same check through +# whichever launcher exists. It still fails when the check genuinely cannot run. if [ -d node_modules ]; then - pnpm exec lint-staged + if command -v pnpm >/dev/null 2>&1; then + pnpm exec lint-staged + elif [ -x node_modules/.bin/lint-staged ]; then + echo "pre-commit: pnpm not found, running node_modules/.bin/lint-staged" + node_modules/.bin/lint-staged + else + echo "pre-commit: ERROR - lint-staged is installed nowhere this can reach." + echo " node_modules exists, so this is a broken install rather" + echo " than a fresh checkout. Reinstall dependencies." + exit 1 + fi fi diff --git a/apps/bff/src/config.ts b/apps/bff/src/config.ts index 375c475..1098ca7 100644 --- a/apps/bff/src/config.ts +++ b/apps/bff/src/config.ts @@ -488,15 +488,23 @@ const schema = z.object({ * `providers.events: ok` while docs/PLAN.md §3 and §11.7 both say the route * is 501 pending Gate L. * - * Gate L is not an engineering gate and it has not passed: the DPAs are - * unsigned, no EU Article 27 representative is appointed, and the EULA that - * has to name the SeatGeek Entities as third-party beneficiaries under their - * clause 4.3 is not published. Serving events before those are true is a - * breach of their terms, not a missing feature, so the failure mode of a - * forgotten environment variable must be "off". Turning events ON is now the - * act that takes a deliberate `SEATGEEK_ENABLED=true`; turning them off - * remains a config flip and a restart, which is what the hours-not-deploys - * obligation above actually requires. + * Serving events before the legal preconditions hold is a breach of their + * terms rather than a missing feature, so the failure mode of a forgotten + * environment variable must be "off". Turning events ON takes a deliberate + * `SEATGEEK_ENABLED=true`; turning them off remains a config flip and a + * restart, which is what the hours-not-deploys obligation above requires. + * + * THE PRECONDITIONS ARE NOT LISTED HERE, AND THAT IS THE LESSON. This comment + * used to enumerate them: the DPAs unsigned, no Article 27 representative + * appointed, the EULA unpublished. Two of the three were false within a day. + * All four processor agreements were executed on 2026-07-30, and the Article + * 27 representative became moot when the service moved to a United States + * posture and the GDPR stopped applying. Nothing failed, because prose has no + * relationship to the thing it describes. + * + * They now live in `lib/legal-triggers.ts`, which the process reads at + * startup and refuses to boot against. Setting this to `true` today is + * refused, naming the three conditions that are still unmet. */ SEATGEEK_ENABLED: z .enum(["true", "false"]) diff --git a/apps/bff/src/index.ts b/apps/bff/src/index.ts index 13355f5..c7b4d95 100644 --- a/apps/bff/src/index.ts +++ b/apps/bff/src/index.ts @@ -7,6 +7,10 @@ */ import { loadConfig } from "./config.js"; +import { + assertLegalTriggersSatisfied, + realTriggerContext, +} from "./lib/legal-triggers.js"; import { buildServer } from "./server.js"; import { buildServices, closeServices } from "./wiring.js"; @@ -15,6 +19,22 @@ async function main(): Promise { // process exits non-zero rather than starting up half-configured. const cfg = loadConfig(); + /** + * Refuse to start when a capability is switched on whose legal preconditions + * are not met. + * + * Deliberately here rather than in `loadConfig`: this reads the legal + * documents from disk, and configuration parsing should stay a pure function + * of the environment. Deliberately before `buildServices`, because the point + * is to refuse the process rather than to serve traffic while a breach of an + * upstream agreement is logged somewhere. + * + * `lib/legal-triggers.ts` explains why this is a registry and not a comment. + * The short version is that the comment was tried, in config.ts, and it was + * wrong within a day. + */ + assertLegalTriggersSatisfied(realTriggerContext(cfg)); + // A bootstrap logger for the wiring phase. Replaced by the Fastify logger for // everything after; this exists only so a construction-time failure is not // silent. diff --git a/apps/bff/src/lib/legal-documents.ts b/apps/bff/src/lib/legal-documents.ts index 22da2b1..2252bf9 100644 --- a/apps/bff/src/lib/legal-documents.ts +++ b/apps/bff/src/lib/legal-documents.ts @@ -189,7 +189,7 @@ export const CONSENT_DOCUMENTS: readonly LegalDocument[] = [ consentEpoch: 1, material: true, contentSha256: - "58801e48df45b3682454f64efe05e7174cce2ff8d14f60fdd8b38d9fd54f5a57", + "180d130e2d6f5e5d8bd66e89015588ce0692d858bc655d432ab15dfe25923963", effectiveAt: null, notes: "DRAFT-1 rewrites section 17 (Changes to these Terms). MATERIAL: it changes how an amendment binds, which is as substantive as a clause gets. DRAFT-0 said 'Continuing to use Pull.fm after a change takes effect means you accept it', which failed on two counts. It is the arrangement rejected in Sgouros v. TransUnion Corp., 817 F.3d 1029 (7th Cir. 2016), binding over Illinois and therefore over these Terms. And it described a WEAKER mechanism than the code implements: the consent epoch in this registry refuses writes until a material revision is affirmatively accepted, so the service never infers agreement from continued use. The clause simultaneously leaned on a rejected theory and understated the control that makes the theory unnecessary. EPOCH STAYS 1 because DRAFT-0 was never published: both live databases hold zero revisions and zero consents, so there is no acceptance to invalidate and no predecessor for this to supersede. The BEFORE INSERT guard in migration 0008 requires a document's first revision to be epoch 1 and material, which this is. Raising it to 2 would assert this supersedes a publication users accepted, and the guard would refuse. Still NOT YET EFFECTIVE pending legal review, hence effectiveAt null.", @@ -201,10 +201,10 @@ export const CONSENT_DOCUMENTS: readonly LegalDocument[] = [ consentEpoch: 1, material: true, contentSha256: - "e3f63c850eda58fc1a47410b57924948c59a0c7987dd85b3d08452facaa55bbb", + "f18244518ebddf6d0c2a45a7740835e92dfdce7a51d2c23ef6b6f9846843782b", effectiveAt: null, notes: - "Residency and retention corrections after the 2026-07-29 cutover out of the European Union. THE CHANGE IS MATERIAL and is recorded as such: three factual disclosures about the user's own data moved. Backups went from an EU-pinned bucket to object storage pinned to NO jurisdiction, so the true claim is 'not EU-pinned' and never 'stored in the United States'; the point-in-time-recovery window went from 6 hours to 7 days, a 28-fold increase in how long deleted data stays restorable; and the four processor agreements became executed and dated. WHY THE EPOCH IS 1 AND NOT 2, since material normally means +1: DRAFT-0 was never published. It was checked rather than assumed - `legal_document_revisions` is empty on staging, the table does not yet exist on prod, and `legal_consents` holds zero rows in both - so there is no first publication for this to supersede and no acceptance anywhere for a raised epoch to invalidate. DRAFT-1 IS the first revision, which the epoch guard in migration 0008 requires to be epoch 1 and material, and epoch 2 is refused by that trigger for exactly this reason. Recording material: true is what keeps this from being a precedent that residency corrections are cosmetic; the epoch is 1 because nothing preceded it, not because the change was small.", + "Residency and retention corrections after the 2026-07-29 cutover out of the European Union. THE CHANGE IS MATERIAL and is recorded as such: three factual disclosures about the user's own data moved. Backups went from an EU-pinned bucket to object storage pinned to NO jurisdiction, so the true claim is 'not EU-pinned' and never 'stored in the United States'; the point-in-time-recovery window went from 6 hours to 7 days, a 28-fold increase in how long deleted data stays restorable; and the four processor agreements became executed and dated. WHY THE EPOCH IS 1 AND NOT 2, since material normally means +1: DRAFT-0 was never published. It was checked rather than assumed - `legal_document_revisions` is empty on staging, the table does not yet exist on prod, and `legal_consents` holds zero rows in both - so there is no first publication for this to supersede and no acceptance anywhere for a raised epoch to invalidate. DRAFT-1 IS the first revision, which the epoch guard in migration 0008 requires to be epoch 1 and material, and epoch 2 is refused by that trigger for exactly this reason. Recording material: true is what keeps this from being a precedent that residency corrections are cosmetic; the epoch is 1 because nothing preceded it, not because the change was small. EDITED IN PLACE ON 2026-07-30, VERSION DELIBERATELY HELD, after an audit of section 10 against the United States state privacy statutes. What changed: Connecticut now has its own subsection, because Public Act 25-113 took effect on 2026-07-01 and removed the volume threshold from two of the CTDPA's three applicability triggers, which means the surrounding 'below the threshold' reasoning does not reach it and the real reason Pull.fm is outside it is the absence of sensitive data. That subsection carries an [OPEN] marker for the question counsel has to answer, whether listening history is data 'revealing' religious belief or sexual orientation within that definition, which section 3.6 already concedes is arguable. Also added: a 'Checked, and deliberately not implemented' subsection recording that Global Privacy Control, ADMT disclosure and the CCPA disclosure set were each examined and each correctly omitted, so a later reader does not re-open them; Rhode Island named as the lowest numeric bar in the country and therefore the first threshold growth would cross; and the current CPI-adjusted CCPA revenue figure quoted for orientation only, with the statutory figure kept as the operative one because the adjustment is automatic. HELD AT DRAFT-1 RATHER THAN BUMPED, on the precedent recorded under consent-presentation: editing an unpublished draft in place is not a revision, ensureRevisions only refuses REDEFINING a published version, DRAFT-1 has existed in a single commit since 2026-07-29, and both live databases hold zero revisions. The stronger reason not to bump is that DRAFT-2 would be the FIRST row in legal_document_revisions, which the epoch guard in migration 0008 requires to be epoch 1 AND material, and this change is cosmetic under this document's own rubric because it moves no factual disclosure about a user's own data - it is legal analysis about which statutes reach the operator. Bumping would therefore have forced a material: true label invented to satisfy a guard rather than because the change was material. NOTE THE GAP THIS EXPOSES, which is recorded rather than worked around: the 'Highlights checked against' interlock pins id@version, so an in-place edit of an unpublished draft does not turn the consent screen red the way a version bump would. Here that is harmless, because section 3.1 of consent-presentation quotes the Terms and no section number in this document moved, but the interlock is weaker than it reads and should be digest-pinned rather than version-pinned.", }), ]; @@ -261,7 +261,7 @@ export const CONSENT_PRESENTATION: LegalDocument = consentDocument({ consentEpoch: 1, material: true, contentSha256: - "e46cd3ea8965b58d96e4eb59929f7e86539c3b54b662af5f75db0d454a670b68", + "a1973cb393e34433ac4c0b04e02686749cb2d22f2db0e17337c4ea88eda5e80f", effectiveAt: null, notes: "First recorded revision of the consent screen copy: what is displayed, what the affirmative act is, what the button says, what a decline does, and what a returning user is told after a material revision. Epoch 1 and material because it is the first revision of this document, which the epoch guard in migration 0008 requires and which is also the fact - nothing preceded it. NOT A DOCUMENT ANYBODY ACCEPTS: it is published and recorded so that a change to the words around the button is a versioned, reviewable decision rather than a client string edit, and so that the words live on a given date are retrievable afterwards. The materiality rubric for THIS document is in its own section 2 and is narrower than the other two: the question is whether the change alters whether, or to what, assent was communicated. HELD AT DRAFT-0 THROUGH THE 2026-07-30 TERMS REVISION, and the asymmetry with terms-of-service is deliberate. Section 3.1 quotes the liability caps, so the 'Highlights checked against' line had to advance from terms-of-service@DRAFT-0 to DRAFT-1, which moved this file's digest. That edit is COSMETIC under this document's own rubric: it records that a human re-read the highlights and found the quoted figures unchanged, and it alters neither what a user is shown nor what they are asked, which is the only question that makes a change to this document material. The version is held rather than bumped because DRAFT-0 has never been published anywhere: it was written on 2026-07-30, both live databases hold zero revisions, and ensureRevisions only refuses REDEFINING a published version. Editing an unpublished draft in place is not a revision. terms-of-service was bumped to DRAFT-1 instead because its DRAFT-0 has existed across many commits and may sit in a developer database, where redefining it would fail. Note also that a cosmetic bump here would be REFUSED by the epoch guard: the first revision of any document must be epoch 1 AND material, so a DRAFT-1 marked cosmetic could not be the first row.", @@ -370,20 +370,63 @@ export function declaredVersion(raw: string): string | null { * Returns null when the line is absent, so the test can say so specifically rather * than failing on an empty comparison. */ +/** + * THE PIN CARRIES A DIGEST BECAUSE A VERSION WAS NOT ENOUGH. + * + * This declared `id@version` until 2026-07-30, and a version only moves when + * somebody decides to move it. An UNPUBLISHED draft is edited in place: the + * documented precedent in this repository is that redefining a version nobody + * has ever been served is not a revision, so `DRAFT-1` can mean different bytes + * on two different days. Both consent documents were rewritten twice that day + * without their versions changing, and this interlock stayed green through all + * of it, certifying as re-read a set of figures sitting in text nobody had + * re-read. + * + * That is the defect the digest lock exists to prevent, reintroduced one layer + * up: a check that reports success because the thing it compares did not happen + * to be the thing that changed. + * + * So the pin is `id@version#digest`, where the digest is a prefix of the + * content hash the registry holds. A prefix rather than all 64 characters + * because this line is read by people and a wrapped hash is a line nobody + * checks; twelve hex characters is 48 bits, far past coincidence for a drift + * check and not defending against a crafted collision. The registry keeps the + * full digest either way. + * + * An entry written in the old `id@version` form is returned with + * `digest: null`, which the test rejects by name: silently accepting it would + * restore exactly the hole this closes. + */ +export interface HighlightSource { + readonly version: string; + /** Prefix of the source document's content digest, or null in the old form. */ + readonly digest: string | null; +} + export function declaredHighlightSources( raw: string, -): Readonly> | null { +): Readonly> | null { const line = /^\*\*Highlights checked against:\*\*\s*(.+)$/m.exec(raw); if (line === null) return null; - const out: Record = {}; - for (const [, id, version] of line[1]?.matchAll( - /`([a-z][a-z0-9-]{2,63})@([A-Za-z0-9][A-Za-z0-9._-]{0,63})`/g, + const out: Record = {}; + for (const [, id, version, digest] of line[1]?.matchAll( + /`([a-z][a-z0-9-]{2,63})@([A-Za-z0-9][A-Za-z0-9._-]{0,63}?)(?:#([0-9a-f]{8,64}))?`/g, ) ?? []) { - if (id !== undefined && version !== undefined) out[id] = version; + if (id === undefined || version === undefined) continue; + out[id] = { version, digest: digest ?? null }; } return out; } +/** + * How much of a content digest the `Highlights checked against` line carries. + * + * Twelve is a floor rather than a format: a longer prefix in the document is + * accepted and compared at this length, so writing a more careful line later + * does not invalidate it. + */ +export const HIGHLIGHT_DIGEST_PREFIX = 12; + /** The highest epoch a subject has accepted, per document id. */ export type AcceptedEpochs = Readonly>; diff --git a/apps/bff/src/lib/legal-triggers.ts b/apps/bff/src/lib/legal-triggers.ts new file mode 100644 index 0000000..f16d6b4 --- /dev/null +++ b/apps/bff/src/lib/legal-triggers.ts @@ -0,0 +1,505 @@ +/** + * Legal triggers: what becomes load-bearing when a capability is switched on. + * + * --------------------------------------------------------------------------- + * WHY THIS FILE EXISTS + * + * The rest of the legal machinery answers "did the document change?". Digests + * are pinned, epochs are guarded, a formatter run is not an amendment. All of + * it protects the DOCUMENT from drifting. + * + * None of it protects the document from the CODE. Every claim in + * `legal/privacy-policy.md` is true today because somebody wrote it after + * reading the system, and stays true only for as long as nobody changes the + * system without rereading it. Enabling a feature is the single moment where + * the largest number of those claims can become false at once, and it is + * usually one boolean. + * + * So this is the interception point. A capability declares, in code, what has + * to be true before it may be on. Turning it on without those things is a + * startup failure rather than a discovery made later by a regulator. + * + * --------------------------------------------------------------------------- + * WHY IT IS NOT A COMMENT + * + * Because a comment was tried and it rotted inside a day. + * + * `SEATGEEK_ENABLED` in `config.ts` carried exactly this reasoning, written + * carefully by somebody who understood it, listing the preconditions for + * enabling events: the DPAs unsigned, no Article 27 representative appointed, + * the EULA unpublished. Two of those three were false within twenty-four + * hours. All four processor agreements were executed on 2026-07-30, and the + * Article 27 representative became moot when the service moved to a United + * States posture and the GDPR stopped applying. + * + * Nothing failed. Nothing could have failed, because prose has no relationship + * to the thing it describes. A registry that tests read cannot rot that way. + * + * --------------------------------------------------------------------------- + * THE TWO KINDS OF PRECONDITION, AND WHY THE SECOND ONE EXISTS + * + * A `checkable` precondition is proved by a predicate. It is worth exactly as + * much as its ability to FAIL, so every one of them has a test that mutates + * the input and asserts it goes red. A predicate that cannot fail is the house + * defect of this repository and is not permitted here. + * + * An `attested` precondition is one no code can prove: that a screen exists and + * a human saw it, that a document is published at a URL somebody will keep + * alive. Rather than omit these, which would let a capability look fully gated + * while its most important condition went unrecorded, they are satisfied only + * by a dated attestation written into this file. That makes enabling such a + * capability a reviewed code change rather than an environment variable flip, + * and it leaves an audit trail naming who checked and when. + * + * An attested precondition with `attestedOn: null` BLOCKS. That is the point. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { Config } from "../config.js"; +import { resolveLegalRoot } from "./legal-source.js"; + +/** The result of evaluating one precondition. */ +export type PreconditionOutcome = + | { readonly satisfied: true } + | { readonly satisfied: false; readonly because: string }; + +export const SATISFIED: PreconditionOutcome = { satisfied: true }; + +export function unsatisfied(because: string): PreconditionOutcome { + return { satisfied: false, because }; +} + +/** + * Everything a precondition may look at. + * + * Legal documents are read through an injected reader rather than from disk + * directly, so a test can hand a predicate a mutated document and prove the + * predicate notices. A predicate that read the real file itself could only ever + * be tested against the one input that makes it pass. + */ +export interface TriggerContext { + readonly config: Config; + /** Reads a repository-relative path, for example `legal/terms-of-service.md`. */ + readonly readLegal: (relativePath: string) => string; +} + +export interface CheckablePrecondition { + readonly kind: "checkable"; + readonly id: string; + /** What must be true, as a sentence, for an error message a human can act on. */ + readonly requirement: string; + readonly check: (ctx: TriggerContext) => PreconditionOutcome; +} + +export interface AttestedPrecondition { + readonly kind: "attested"; + readonly id: string; + readonly requirement: string; + /** + * The item in `docs/compliance/publication-checklist.md` that tracks this, + * so an unsatisfied attestation points at the work rather than at a wall. + */ + readonly blocks: string; + /** ISO date on which a human verified it. `null` blocks the capability. */ + readonly attestedOn: string | null; + /** Who verified it. Meaningless without `attestedOn` and required with it. */ + readonly attestedBy: string | null; +} + +export type Precondition = CheckablePrecondition | AttestedPrecondition; + +export interface LegalTrigger { + /** What the capability is, in a reader's words. */ + readonly capability: string; + /** The environment variable that turns it on. */ + readonly flag: string; + /** Whether the capability is live in a resolved configuration. */ + readonly isEnabled: (config: Config) => boolean; + /** + * Whether switching this on changes what a user agreed to. `material` means + * the consent epoch has to move and every user must accept again. + */ + readonly enabling: "material" | "cosmetic"; + /** + * Document sections that become load-bearing once this is on. Free text by + * design: these are pointers for a human reviewer, and a test asserts the + * documents they name exist. + */ + readonly documents: readonly string[]; + readonly preconditions: readonly Precondition[]; + /** + * Why a capability with no preconditions has none. Required exactly when + * `preconditions` is empty, so "nothing to check" is always a recorded + * decision and never an oversight. + */ + readonly inertBecause?: string; +} + +// --------------------------------------------------------------------------- +// Predicates. +// +// Each is exported so its own non-vacuity test can reach it, and each looks at +// one thing. A predicate that checked several would report a failure that does +// not say which. +// --------------------------------------------------------------------------- + +const TERMS = "legal/terms-of-service.md"; +const PRIVACY = "legal/privacy-policy.md"; + +/** + * Our cap on the SeatGeek Entities must still be the figure their own terms set + * for themselves. + * + * Their clause 4.3 requires terms "at least as protective of the SeatGeek + * Entities as the terms hereof" and names limitations of liability as an + * express example. Their clause 8.2 caps them at USD 50. This was USD 100 until + * 2026-07-29, which meant our clause left them at twice the exposure their own + * contract permits, in the one respect 4.3 calls out by name. + * + * The honest limit of this check: it catches OUR figure moving. It cannot catch + * THEIRS moving, because their terms let them change without notice to us. That + * is why checklist item B8 requires a quarterly re-audit, and why this predicate + * is not the whole control. + */ +export const seatgeekCapMatchesTheirs: CheckablePrecondition = { + kind: "checkable", + id: "seatgeek-cap-is-fifty", + requirement: + "Terms section 13 must cap the SeatGeek Entities at USD 50.00, the figure " + + "their own clause 8.2 sets, in a bullet naming them", + check: ({ readLegal }) => { + const terms = readLegal(TERMS); + // `\s+` rather than a literal space: the formatter owns line breaks, and a + // predicate that failed when Prettier rewrapped a paragraph would be + // disabled within a week. + if (!/FIFTY UNITED STATES\s+DOLLARS \(USD 50\.00\)/.test(terms)) { + return unsatisfied( + "Terms section 13 no longer states a USD 50.00 cap for the SeatGeek " + + "Entities. Their clause 8.2 caps them at that figure and their 4.3 " + + "requires ours to be at least as protective.", + ); + } + if (!terms.includes("SEATGEEK ENTITIES")) { + return unsatisfied( + "The USD 50.00 cap is present but no longer names the SeatGeek " + + "Entities. Protection extended to a third-party beneficiary must be " + + "findable by searching for that beneficiary's name (Sosa v. Onfido).", + ); + } + return SATISFIED; + }, +}; + +/** + * The third-party beneficiary grant must survive, and must still enumerate the + * limitation-of-liability section. + * + * `Sosa v. Onfido`, 8 F.4th 631 (7th Cir. 2021) turned on a third party falling + * outside the class a limitation protected. Illinois requires third-party + * benefit to be practically an express declaration, so an enumeration that + * points at the wrong section is the specific defect that decided that case. + */ +export const seatgeekBeneficiaryGrantIntact: CheckablePrecondition = { + kind: "checkable", + id: "seatgeek-beneficiary-grant-intact", + requirement: + "Terms section 9 must name the SeatGeek Entities as express third-party " + + "beneficiaries and enumerate the limitation-of-liability section", + check: ({ readLegal }) => { + const terms = readLegal(TERMS); + if (!/express third-party\s+beneficiaries/.test(terms)) { + return unsatisfied( + "Terms section 9 no longer grants the SeatGeek Entities express " + + "third-party beneficiary status, which SeatGeek clause 4.3 requires.", + ); + } + if (!terms.includes("section 13 (limitation of liability)")) { + return unsatisfied( + "The beneficiary grant no longer enumerates section 13 (limitation of " + + "liability). An enumeration pointing away from the cap is the defect " + + "that decided Sosa v. Onfido.", + ); + } + return SATISFIED; + }, +}; + +/** + * The events feature must not be able to receive precise location, and the + * privacy policy must still say so. + * + * SeatGeek's terms forbid personal data reaching their API. The service-side + * control is a validator; this predicate guards the disclosure that describes + * it, so the two cannot drift apart silently. + */ +export const privacyDisclaimsPreciseLocation: CheckablePrecondition = { + kind: "checkable", + id: "privacy-disclaims-precise-location", + requirement: + "Privacy policy section 3.6 must still disclaim precise location and state " + + "that the events feature accepts a city name only", + check: ({ readLegal }) => { + const privacy = readLegal(PRIVACY); + if (!privacy.includes("No precise location")) { + return unsatisfied( + "Privacy policy section 3.6 no longer disclaims precise location, " + + "which the events feature depends on being true.", + ); + } + if (!privacy.includes("city name only")) { + return unsatisfied( + "Privacy policy section 3.6 no longer states that the events feature " + + "accepts a city name only. SeatGeek's terms forbid personal data " + + "reaching their API.", + ); + } + return SATISFIED; + }, +}; + +/** + * Events may not be served without the credential that fetches them. + * + * A deployment with `SEATGEEK_ENABLED=true` and no client id would report the + * capability as on while serving nothing, which makes every other check here a + * statement about a feature that is not running. + */ +export const eventsCredentialPresent: CheckablePrecondition = { + kind: "checkable", + id: "events-credential-present", + requirement: "SEATGEEK_CLIENT_ID must be set when SEATGEEK_ENABLED is true", + check: ({ config }) => + config.SEATGEEK_CLIENT_ID === undefined + ? unsatisfied( + "SEATGEEK_ENABLED is true but SEATGEEK_CLIENT_ID is unset, so the " + + "capability reports as enabled while serving nothing.", + ) + : SATISFIED, +}; + +/** + * The MusicBrainz mirror must not be used without the attribution the upstream + * licence requires. + */ +export const musicbrainzAttributionPresent: CheckablePrecondition = { + kind: "checkable", + id: "musicbrainz-attribution-present", + requirement: + "legal/attribution.md must credit MusicBrainz when the local mirror is used", + check: ({ readLegal }) => { + let attribution: string; + try { + attribution = readLegal("legal/attribution.md"); + } catch { + return unsatisfied( + "legal/attribution.md is missing, and the MusicBrainz licence requires " + + "attribution wherever its data is used.", + ); + } + return /MusicBrainz/i.test(attribution) + ? SATISFIED + : unsatisfied( + "legal/attribution.md no longer credits MusicBrainz, which its " + + "licence requires.", + ); + }, +}; + +// --------------------------------------------------------------------------- +// The registry. +// --------------------------------------------------------------------------- + +export const LEGAL_TRIGGERS: readonly LegalTrigger[] = [ + { + capability: "Live event data from SeatGeek", + flag: "SEATGEEK_ENABLED", + isEnabled: (config) => config.eventsEnabled, + enabling: "material", + documents: [ + "legal/terms-of-service.md#8", + "legal/terms-of-service.md#9", + "legal/terms-of-service.md#13", + "legal/privacy-policy.md#3.6", + "legal/privacy-policy.md#9.3", + ], + preconditions: [ + eventsCredentialPresent, + seatgeekCapMatchesTheirs, + seatgeekBeneficiaryGrantIntact, + privacyDisclaimsPreciseLocation, + { + kind: "attested", + id: "consent-gate-presents-documents", + requirement: + "A Pull.fm client must display these documents and require an " + + "affirmative acceptance before use. SeatGeek clause 4.3 requires the " + + "Application to display the EULA, requires each End User to accept it " + + "before using the Application, obliges us to use all reasonable " + + "efforts to enforce it, and separately forbids collecting information " + + "from an End User who has not affirmatively authorised it. Enabling " + + "events without the screen breaches that clause in three places at " + + "once, and the third is not limited to SeatGeek data.", + blocks: "B6", + attestedOn: null, + attestedBy: null, + }, + { + kind: "attested", + id: "documents-published-at-stable-url", + requirement: + "Both documents must be served at a stable URL, byte-identical to the " + + "canonical source, because the API refuses an acceptance whose digest " + + "does not match the version it publishes. A rendered page whose bytes " + + "differ makes acceptance impossible rather than merely inconsistent.", + blocks: "B6", + attestedOn: null, + attestedBy: null, + }, + { + kind: "attested", + id: "counsel-confirmed-seatgeek-clauses", + requirement: + "Counsel must confirm that sections 9 and 13 are effective under " + + "Illinois law and satisfy SeatGeek clause 4.3, addressing whether " + + "matching their USD 50 figure discharges it, whether 'at least as " + + "protective' reaches conspicuousness, and whether the drafting " + + "survives Sosa v. Onfido.", + blocks: "B7", + attestedOn: null, + attestedBy: null, + }, + ], + }, + { + capability: "Local MusicBrainz mirror", + flag: "MB_LOCAL_ENABLED", + isEnabled: (config) => config.MB_LOCAL_ENABLED, + enabling: "cosmetic", + documents: ["legal/attribution.md", "legal/privacy-policy.md#9.3"], + preconditions: [musicbrainzAttributionPresent], + }, + { + capability: "Public API documentation", + flag: "DOCS_ENABLED", + isEnabled: (config) => config.DOCS_ENABLED, + enabling: "cosmetic", + documents: [], + preconditions: [], + inertBecause: + "Serving the OpenAPI description discloses the shape of the API and no " + + "personal information, creates no obligation to a user or an upstream " + + "provider, and changes nothing a user agreed to. Recorded as inert rather " + + "than omitted so that 'nothing to check' is a decision on the record.", + }, +]; + +// --------------------------------------------------------------------------- +// Enforcement. +// --------------------------------------------------------------------------- + +/** + * The context a running service uses. + * + * Legal documents are resolved through `resolveLegalRoot`, the same helper that + * serves the canonical bytes and seeds `legal_document_revisions.content`, so + * there is one answer to "where do the legal documents live" rather than two + * that can disagree. `legal/` is copied into the runtime image deliberately; + * the Dockerfile records what broke when it was not. + */ +export function realTriggerContext(config: Config): TriggerContext { + return { + config, + readLegal: (relativePath) => { + // Resolved per read rather than once, so constructing a context never + // throws. A deployment with every gated capability switched off has no + // reason to fail here, and the error should belong to the capability that + // actually needed the document. + const root = resolveLegalRoot(); + if (root === null) { + throw new Error( + `cannot verify legal preconditions: the legal/ directory was not ` + + `found, so ${relativePath} cannot be read. It is copied into the ` + + `runtime image deliberately (see apps/bff/Dockerfile); its absence ` + + `is a packaging defect, not a reason to proceed unverified. Set ` + + `LEGAL_SOURCE_DIR if this deployment's layout is unusual.`, + ); + } + return readFileSync(join(root, relativePath), "utf8"); + }, + }; +} + +export interface TriggerFailure { + readonly capability: string; + readonly flag: string; + readonly preconditionId: string; + readonly because: string; +} + +/** + * Evaluates every enabled capability and returns what is not satisfied. + * + * Disabled capabilities are not evaluated. A precondition on something that is + * off is a statement about a hypothetical, and failing a deployment for it + * would train people to disable the check rather than to satisfy it. + */ +export function evaluateLegalTriggers(ctx: TriggerContext): TriggerFailure[] { + const failures: TriggerFailure[] = []; + for (const trigger of LEGAL_TRIGGERS) { + if (!trigger.isEnabled(ctx.config)) continue; + for (const pre of trigger.preconditions) { + if (pre.kind === "attested") { + if (pre.attestedOn === null) { + failures.push({ + capability: trigger.capability, + flag: trigger.flag, + preconditionId: pre.id, + because: + `${pre.requirement}\n Nobody has attested this. It is tracked ` + + `as checklist item ${pre.blocks}. To proceed, verify it and record ` + + `attestedOn and attestedBy in legal-triggers.ts, which is a ` + + `reviewed change rather than a configuration flip.`, + }); + } + continue; + } + const outcome = pre.check(ctx); + if (!outcome.satisfied) { + failures.push({ + capability: trigger.capability, + flag: trigger.flag, + preconditionId: pre.id, + because: outcome.because, + }); + } + } + } + return failures; +} + +/** + * Refuses to start when an enabled capability has an unsatisfied precondition. + * + * Fail-closed, matching the webhook signing secret: the failure mode of a + * forgotten step must be "off", because the alternative is a service that + * breaches an upstream agreement quietly and for as long as nobody looks. + */ +export function assertLegalTriggersSatisfied(ctx: TriggerContext): void { + const failures = evaluateLegalTriggers(ctx); + if (failures.length === 0) return; + const detail = failures + .map( + (f) => + ` ${f.flag} (${f.capability})\n [${f.preconditionId}] ${f.because}`, + ) + .join("\n\n"); + throw new Error( + `refusing to start: ${String(failures.length)} legal precondition(s) are not ` + + `satisfied for capabilities that are switched on.\n\n${detail}\n\n` + + `Each of these is a condition of a published legal document or an ` + + `upstream agreement, not an engineering preference. Turn the capability ` + + `off, or satisfy the condition.`, + ); +} diff --git a/apps/bff/src/lib/recurring-audits.ts b/apps/bff/src/lib/recurring-audits.ts new file mode 100644 index 0000000..1e89a78 --- /dev/null +++ b/apps/bff/src/lib/recurring-audits.ts @@ -0,0 +1,154 @@ +/** + * Obligations that lapse with time rather than with a code change. + * + * --------------------------------------------------------------------------- + * WHY THESE NEED A MECHANISM AT ALL + * + * `legal-triggers.ts` intercepts the moment a capability is switched on. + * `legal-claims.test.ts` intercepts a change to the system that falsifies a + * document. Neither can see the third way a legal position goes wrong, which is + * that nothing changes here and the world moves anyway. + * + * Two of those are live today. SeatGeek's own terms permit them to change their + * terms at any time, with continued use as acceptance and no notice to us, so + * the cap our clause has to match can drop below USD 50 while this repository + * sits untouched. And state privacy statutes are amended on their own schedule: + * Connecticut removed the volume threshold from two of its three applicability + * triggers on 1 July 2026, which converted "we are below the threshold" from an + * argument into a non-sequitur, and nothing in this repository would have + * noticed. + * + * The existing answer was "re-audit quarterly", written in a checklist. An + * intention with no trigger is the thing this codebase keeps rejecting + * everywhere else, so it is rejected here too. + * + * --------------------------------------------------------------------------- + * WHY A FAILING TEST AND NOT A REMINDER + * + * Because a reminder is another intention. A test that goes red on a date is + * the same shape as a certificate expiry check: disruptive exactly in + * proportion to how overdue it is, impossible to not notice, and cleared by + * doing the work rather than by dismissing a notification. + * + * The remedy is always the same and is stated in the failure: do the audit, + * record the date and who did it, and if the audit found something, fix that + * first. Moving the date without doing the audit is possible, as it is with + * every control that ends in a human, but it is a lie told in a reviewable diff + * rather than a task quietly not done. + */ + +export interface RecurringAudit { + readonly id: string; + /** What has to be re-checked, specifically enough to act on. */ + readonly what: string; + /** What goes wrong if it lapses. The reason somebody should care today. */ + readonly why: string; + readonly intervalDays: number; + /** ISO date the audit was last actually performed. */ + readonly lastCompletedOn: string; + readonly lastCompletedBy: string; + /** Where the result of the last one is recorded. */ + readonly evidence: string; + /** The item in docs/compliance/publication-checklist.md that tracks it. */ + readonly blocks: string; +} + +export const RECURRING_AUDITS: readonly RecurringAudit[] = [ + { + id: "seatgeek-api-terms", + what: + "Re-read SeatGeek's API Terms of Use against the transcript in " + + "packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md. In " + + "particular clause 8.2, the cap they place on themselves, and clause 4.3, " + + "the protections our EULA has to match.", + why: + "Their section 1 lets them change their terms at any time with continued " + + "use as acceptance and no notice to us. If their 8.2 cap drops below USD " + + "50, the fourth bullet of terms section 13 is immediately less protective " + + "than 4.3 requires, and we would not know. The predicate in " + + "legal-triggers.ts catches OUR figure moving and cannot catch theirs; " + + "this is the other half of that control.", + intervalDays: 90, + lastCompletedOn: "2026-07-29", + lastCompletedBy: "operator", + evidence: "packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md", + blocks: "B8", + }, + { + id: "state-privacy-law-survey", + what: + "Re-check the state comprehensive privacy statutes against primary " + + "sources: applicability thresholds, the definition of sensitive data, and " + + "any statute newly in force. Privacy policy section 10.2 states a " + + "position on all of them.", + why: + "Connecticut removed the volume threshold from two of its three " + + "applicability triggers on 1 July 2026, so the reasoning that Pull.fm is " + + "below every threshold stopped reaching it. That amendment was signed in " + + "2025 and took effect a year later; nothing here would have surfaced it. " + + "Indiana, Kentucky and Rhode Island took effect on 1 January 2026 and " + + "more are scheduled.", + intervalDays: 180, + lastCompletedOn: "2026-07-30", + lastCompletedBy: "operator", + evidence: "legal/privacy-policy.md section 10.2", + blocks: "A6", + }, +]; + +export interface OverdueAudit { + readonly audit: RecurringAudit; + readonly dueOn: string; + readonly daysOverdue: number; +} + +const DAY_MS = 86_400_000; + +/** The date an audit next falls due, as an ISO date. */ +export function dueDate(audit: RecurringAudit): string { + const last = Date.parse(`${audit.lastCompletedOn}T00:00:00Z`); + return new Date(last + audit.intervalDays * DAY_MS) + .toISOString() + .slice(0, 10); +} + +/** + * Audits that are past due at `now`. + * + * `now` is a parameter rather than read from the clock so the overdue + * arithmetic can be tested against fixed dates. A function that read the clock + * itself could only ever be tested on the one day the suite happened to run, + * which for a date-based check is no test at all. + */ +export function overdueAudits( + now: Date, + audits: readonly RecurringAudit[] = RECURRING_AUDITS, +): OverdueAudit[] { + const out: OverdueAudit[] = []; + for (const audit of audits) { + const due = Date.parse(`${dueDate(audit)}T00:00:00Z`); + if (now.getTime() > due) { + out.push({ + audit, + dueOn: dueDate(audit), + daysOverdue: Math.floor((now.getTime() - due) / DAY_MS), + }); + } + } + return out; +} + +/** A failure message that says what to do, not merely what is wrong. */ +export function describeOverdue(overdue: readonly OverdueAudit[]): string { + return overdue + .map( + (o) => + ` ${o.audit.id} was due ${o.dueOn} and is ${String(o.daysOverdue)} day(s) ` + + `overdue.\n What: ${o.audit.what}\n Why it matters: ${o.audit.why}\n` + + ` Evidence lives in: ${o.audit.evidence}\n` + + ` Tracked as checklist item ${o.audit.blocks}.\n` + + ` To clear: perform the audit, fix anything it finds FIRST, then ` + + `update lastCompletedOn and lastCompletedBy in lib/recurring-audits.ts.`, + ) + .join("\n\n"); +} diff --git a/apps/bff/test/integration/legal-claims.test.ts b/apps/bff/test/integration/legal-claims.test.ts new file mode 100644 index 0000000..8851ed0 --- /dev/null +++ b/apps/bff/test/integration/legal-claims.test.ts @@ -0,0 +1,371 @@ +/** + * Tests that the published legal documents are still true of this system. + * + * --------------------------------------------------------------------------- + * THE GAP THIS CLOSES + * + * `legal-versions.test.ts` asserts things about the documents: that they exist, + * that digests are well-formed, that a formatter run is not an amendment. It + * protects them from drifting. + * + * `legal-triggers.test.ts` asserts that a capability is not switched on before + * its legal preconditions hold. It protects the moment of enabling. + * + * Neither notices ordinary development. Somebody adds an analytics dependency + * on a Tuesday and privacy policy section 3.6 becomes a false statement of fact + * under section 5 of the FTC Act, with every gate still green. This file is the + * third leg: the claims the documents make about what the system does and does + * not do, asserted against the system. + * + * --------------------------------------------------------------------------- + * WHY SECTION F IS TESTED RATHER THAN TRUSTED + * + * Until 2026-07-30 the privacy policy named database tables and columns in + * prose. That made every claim checkable and made the document read as a schema + * description, so the names moved to section F of the publication checklist. + * + * The names were the coupling. A developer renaming `users.email` used to at + * least meet the policy in a grep. Section F on its own is inert prose with + * nothing asserting it is current, which trades a misrepresentation risk for a + * drift risk. That trade is only correct if the drift risk is closed, so this + * file closes it: every identifier section F names must exist. + */ + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, test } from "vitest"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = join(HERE, "..", "..", "..", ".."); + +const read = (relative: string): string => + readFileSync(join(REPO, relative), "utf8"); + +const CHECKLIST = "docs/compliance/publication-checklist.md"; +const PRIVACY = "legal/privacy-policy.md"; + +const MIGRATIONS = join(REPO, "packages", "db", "migrations"); +const schemaCorpus = (): string => + readdirSync(MIGRATIONS) + .filter((f) => f.endsWith(".sql")) + .map((f) => readFileSync(join(MIGRATIONS, f), "utf8")) + .join("\n"); + +const packageJsonCorpus = (): string => + ["package.json", "apps/bff/package.json", "packages/db/package.json"] + .filter((p) => existsSync(join(REPO, p))) + .map(read) + .join("\n"); + +// --------------------------------------------------------------------------- + +/** Everything inside backticks in section F of the checklist. */ +function sectionFTokens(): string[] { + const doc = read(CHECKLIST); + const start = doc.indexOf("## F."); + expect(start, "section F is missing from the checklist").toBeGreaterThan(-1); + const rest = doc.slice(start + 1); + const end = rest.indexOf("\n## "); + const section = end === -1 ? rest : rest.slice(0, end); + return [...new Set([...section.matchAll(/`([^`]+)`/g)].map((m) => m[1]!))]; +} + +type Classified = + | { kind: "file"; token: string } + | { kind: "env"; token: string } + | { kind: "script"; token: string } + | { kind: "schema"; token: string; parts: string[] } + | { kind: "prose"; token: string }; + +function classify(token: string): Classified { + if (/^[\w./-]+\.(ts|mjs|js|tf|sql|md)$/.test(token)) { + return { kind: "file", token }; + } + if (/^[A-Z][A-Z0-9_]{4,}$/.test(token)) return { kind: "env", token }; + if (/^[a-z]+:[a-z]+$/.test(token)) return { kind: "script", token }; + if (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/.test(token)) { + return { kind: "schema", token, parts: token.split(".") }; + } + // Routes, SQL fragments, CIDR suffixes, provider region names and settings + // written as `key = value`. Real content, but not an identifier this file can + // resolve, so it is skipped rather than guessed at. + return { kind: "prose", token }; +} + +describe("section F of the checklist names things that exist", () => { + const tokens = sectionFTokens(); + const classified = tokens.map(classify); + + test("the parser actually found something to check", () => { + // Without this, a classifier that silently stops matching turns every + // assertion below into a loop over an empty list, which is the exact shape + // of a check that reports success while checking nothing. + const resolvable = classified.filter((c) => c.kind !== "prose"); + expect( + resolvable.length, + `only ${String(resolvable.length)} of ${String(tokens.length)} section F tokens were ` + + `resolvable. Either section F was emptied or the classifier stopped ` + + `matching; both make this suite vacuous.`, + ).toBeGreaterThanOrEqual(15); + }); + + test("every file it names exists", () => { + for (const c of classified) { + if (c.kind !== "file") continue; + expect( + existsSync(join(REPO, c.token)), + `section F names ${c.token}, which does not exist`, + ).toBe(true); + } + }); + + test("every environment variable it names is declared in config", () => { + const config = read("apps/bff/src/config.ts"); + for (const c of classified) { + if (c.kind !== "env") continue; + expect( + config.includes(c.token), + `section F names ${c.token}, which config.ts no longer declares`, + ).toBe(true); + } + }); + + test("every script it names is defined in a package", () => { + const packages = packageJsonCorpus(); + for (const c of classified) { + if (c.kind !== "script") continue; + expect( + packages.includes(`"${c.token}"`), + `section F names the script ${c.token}, which no package defines`, + ).toBe(true); + } + }); + + test("every table and column it names is in the schema", () => { + // Tables and columns are not distinguished. Asserting that the identifier + // appears somewhere in the migrations catches the rename, which is the + // failure this exists for, without this file having to model the schema. + const schema = schemaCorpus(); + for (const c of classified) { + if (c.kind !== "schema") continue; + for (const part of c.parts) { + expect( + schema.includes(part), + `section F names ${c.token}, but "${part}" appears in no migration. ` + + `If it was renamed, the privacy policy sentence mapped to it may no ` + + `longer describe anything.`, + ).toBe(true); + } + } + }); +}); + +describe("the negatives in privacy policy section 3.6 are true", () => { + // These are the highest-consequence claims in the document and the cheapest + // to violate, because each is one dependency away. They are also the claims a + // reader is least able to verify and most likely to rely on. + + test("no analytics, telemetry or error-reporting dependency exists", () => { + const forbidden = [ + "@google-analytics", + "gtag", + "react-ga", + "@segment/", + "analytics-node", + "posthog", + "mixpanel", + "@amplitude/", + "amplitude-js", + "@sentry/", + "bugsnag", + "rollbar", + "datadog", + "dd-trace", + "newrelic", + "logrocket", + "fullstory", + "hotjar", + "@vercel/analytics", + "plausible", + "fathom-client", + ]; + const manifests = readdirSync(REPO, { withFileTypes: true }); + expect(manifests.length).toBeGreaterThan(0); + + const corpus = packageJsonCorpus(); + const found = forbidden.filter((dep) => corpus.includes(dep)); + expect( + found, + `privacy policy section 3.6 states there is no analytics, tracking or ` + + `third-party error reporting of any kind. These dependencies contradict ` + + `it: ${found.join(", ")}. A privacy policy is a representation under ` + + `section 5 of the FTC Act; the claim has to be removed before the ` + + `dependency is added.`, + ).toEqual([]); + }); + + test("no payment dependency exists", () => { + const corpus = packageJsonCorpus(); + const found = ["stripe", "@paypal", "braintree", "square"].filter((dep) => + corpus.includes(`"${dep}`), + ); + expect( + found, + `privacy policy section 3.6 states Pull.fm is free and accepts no ` + + `payments. Found: ${found.join(", ")}`, + ).toEqual([]); + }); + + test("no schema column stores precise location", () => { + const schema = schemaCorpus(); + const forbidden = [ + "latitude", + "longitude", + "\\blat\\b", + "\\blng\\b", + "\\blon\\b", + "coordinates", + "geolocation", + "gps", + ]; + const found = forbidden.filter((p) => new RegExp(p, "i").test(schema)); + expect( + found, + `privacy policy section 3.6 states no precise location is collected and ` + + `that the events feature accepts a city name only. These schema ` + + `identifiers contradict it: ${found.join(", ")}. SeatGeek's terms ` + + `separately forbid personal data reaching their API.`, + ).toEqual([]); + }); + + test("no schema column stores biometric data or a password", () => { + // BIPA (740 ILCS 14) applies to a private entity of any size and is + // enforced by private plaintiffs, so this claim carries more exposure than + // anything else in section 3.6. + const schema = schemaCorpus(); + const forbidden = [ + "biometric", + "voiceprint", + "fingerprint", + "faceprint", + "face_geometry", + "iris_scan", + "retina", + "password_hash", + "password_digest", + ]; + const found = forbidden.filter((p) => new RegExp(p, "i").test(schema)); + expect( + found, + `privacy policy section 3.6 disclaims biometric information absolutely ` + + `and states there is no password field. Found: ${found.join(", ")}`, + ).toEqual([]); + }); + + test("the claims these tests defend are still the ones in the document", () => { + // If somebody softens section 3.6, the assertions above keep passing while + // defending sentences that no longer exist. This pins them to the text. + const privacy = read(PRIVACY); + for (const phrase of [ + "No advertising or analytics of any kind", + "No third-party crash or error reporting", + "No precise location", + "No biometric identifiers and no biometric information of any kind", + "No passwords", + "No payment information", + ]) { + expect( + privacy.includes(phrase), + `privacy policy section 3.6 no longer contains "${phrase}", so the ` + + `test defending it is now guarding a claim the document does not make.`, + ).toBe(true); + } + }); +}); + +/** + * Foreign keys to `users(id)` that do not cascade on delete. + * + * Extracted so it can be run against synthetic schemas below. A detector only + * ever exercised on a schema that satisfies it is indistinguishable from one + * that returns an empty array. + */ +export function nonCascadingUserReferences(sql: string): string[] { + const pattern = + /references\s+users\s*\(\s*id\s*\)((?:(?!,\n|\n\s*\)|;)[\s\S]){0,120}?)(?=,\n|\n\s*\)|;)/gi; + const offenders: string[] = []; + for (const match of sql.matchAll(pattern)) { + if (!/on\s+delete\s+cascade/i.test(match[1] ?? "")) { + offenders.push(match[0].replace(/\s+/g, " ").slice(0, 90)); + } + } + return offenders; +} + +describe("the cascade detector can fail", () => { + // Run against schemas this repository does not contain, so the assertion on + // the real schema means "everything cascades" rather than "nothing matched". + + test("a cascading reference is not reported", () => { + expect( + nonCascadingUserReferences( + "create table a (\n user_id uuid not null references users(id) on delete cascade,\n x text\n);", + ), + ).toEqual([]); + }); + + test("a bare reference is caught", () => { + expect( + nonCascadingUserReferences( + "create table b (\n user_id uuid not null references users(id),\n x text\n);", + ), + ).toHaveLength(1); + }); + + test("ON DELETE SET NULL is caught, not mistaken for a cascade", () => { + // The case a substring search for "on delete" would wave through, and the + // one that would silently orphan personal information after an erasure. + expect( + nonCascadingUserReferences( + "create table c (\n user_id uuid not null references users(id) on delete set null,\n x text\n);", + ), + ).toHaveLength(1); + }); +}); + +describe("no user-owned table escapes the deletion claim", () => { + test("every table referencing users declares ON DELETE CASCADE", () => { + // Privacy policy section 7 states that deleting an account removes + // everything linked to it "together, in a single step that either completes + // or does not happen at all". That is only true while every table holding + // user rows cascades. A new table without one makes the sentence false and + // leaves orphaned personal information behind. + // + // packages/db/scripts/verify-migrations.mjs asserts the cascade against a + // real database in CI. This is the cheap static half, so the failure is + // visible without a database and names the legal consequence. + const offenders = nonCascadingUserReferences(schemaCorpus()); + expect( + offenders, + `these foreign keys to users do not cascade on delete, which makes the ` + + `deletion claim in privacy policy section 7 false:\n ` + + offenders.join("\n "), + ).toEqual([]); + }); + + test("the cascade check is not vacuous", () => { + // Proves the pattern above matches real references, so an empty offender + // list means "all cascade" rather than "matched nothing". + const schema = schemaCorpus(); + const references = [ + ...schema.matchAll(/references\s+users\s*\(\s*id\s*\)/gi), + ]; + expect( + references.length, + "no foreign key to users(id) was found in any migration, so the cascade " + + "assertion above is checking nothing", + ).toBeGreaterThan(3); + }); +}); diff --git a/apps/bff/test/integration/legal-triggers.test.ts b/apps/bff/test/integration/legal-triggers.test.ts new file mode 100644 index 0000000..badb42b --- /dev/null +++ b/apps/bff/test/integration/legal-triggers.test.ts @@ -0,0 +1,404 @@ +/** + * Tests for the legal trigger registry. + * + * --------------------------------------------------------------------------- + * THE RULE THIS FILE ENFORCES ON ITSELF + * + * A precondition is worth exactly as much as its ability to fail. A predicate + * that returns `satisfied: true` unconditionally passes every test written the + * obvious way, reports success forever, and checks nothing. That defect has + * been found repeatedly in this repository and it is the reason the legal suite + * already carries "a real edit IS an amendment, so the check is not vacuous". + * + * So every checkable precondition here is tested twice: once against the real + * documents, to prove it does not fail a correct system, and once against a + * mutated copy, to prove it fails a broken one. A predicate with only the first + * test is indistinguishable from `() => SATISFIED`. + * + * The registry is enumerated rather than listed by hand, so a predicate added + * later without a mutation test fails this suite instead of slipping through. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, test } from "vitest"; + +import { loadConfig, type Config } from "../../src/config.js"; +import { + assertLegalTriggersSatisfied, + evaluateLegalTriggers, + LEGAL_TRIGGERS, + type CheckablePrecondition, + type LegalTrigger, + type TriggerContext, +} from "../../src/lib/legal-triggers.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = join(HERE, "..", "..", "..", ".."); + +const readReal = (relative: string): string => + readFileSync(join(REPO, relative), "utf8"); + +/** A configuration with every legally triggered capability switched off. */ +function baseConfig(overrides: Record = {}): Config { + return loadConfig({ + NODE_ENV: "test", + DEPLOY_ENV: "local", + LOG_LEVEL: "silent", + DATABASE_URL: "postgres://u:p@127.0.0.1:5432/pullfm_triggers", + REDIS_URL: "redis://127.0.0.1:6379", + REDIS_QUOTA_URL: "redis://127.0.0.1:6380", + CREDENTIAL_KEKS: `kek:triggers=${Buffer.alloc(32, 1).toString("base64")}`, + CREDENTIAL_ACTIVE_KEK_ID: "kek:triggers", + WORKOS_CLIENT_ID: "client_triggers", + WORKOS_API_KEY: "sk_test_triggers_only", + MUSICBRAINZ_USER_AGENT: "PullFM/0.1.0 (triggers@pull.fm)", + SEATGEEK_ENABLED: "false", + MB_LOCAL_ENABLED: "false", + DOCS_ENABLED: "false", + ...overrides, + }); +} + +function ctx( + config: Config, + readLegal: (p: string) => string = readReal, +): TriggerContext { + return { config, readLegal }; +} + +/** Reads the real document, applies one substitution, returns the rest intact. */ +function mutating( + path: string, + find: string | RegExp, + replace: string, +): (p: string) => string { + return (requested) => { + const raw = readReal(requested); + if (requested !== path) return raw; + const next = raw.replace(find as RegExp, replace); + if (next === raw) { + throw new Error( + `mutation of ${path} changed nothing: the fixture no longer matches ` + + `the document, so any test using it proves nothing`, + ); + } + return next; + }; +} + +const checkable = (t: LegalTrigger): CheckablePrecondition[] => + t.preconditions.filter( + (p): p is CheckablePrecondition => p.kind === "checkable", + ); + +const allCheckable = LEGAL_TRIGGERS.flatMap(checkable); + +// --------------------------------------------------------------------------- + +describe("the trigger registry is well formed", () => { + test("capability and flag names are unique", () => { + const flags = LEGAL_TRIGGERS.map((t) => t.flag); + expect(new Set(flags).size).toBe(flags.length); + const names = LEGAL_TRIGGERS.map((t) => t.capability); + expect(new Set(names).size).toBe(names.length); + }); + + test("precondition ids are unique across the whole registry", () => { + // Ids appear in startup failures and in the checklist. Two preconditions + // sharing one id makes a failure message ambiguous about which broke. + const ids = LEGAL_TRIGGERS.flatMap((t) => t.preconditions.map((p) => p.id)); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("a capability with no preconditions says why it has none", () => { + // "Nothing to check" must be a decision on the record. Without this, an + // unfinished entry and a deliberately inert one look identical. + for (const t of LEGAL_TRIGGERS) { + if (t.preconditions.length === 0) { + expect( + t.inertBecause, + `${t.flag} has no preconditions and no inertBecause`, + ).toBeTruthy(); + expect(t.inertBecause!.length).toBeGreaterThan(40); + } + } + }); + + test("a capability WITH preconditions does not also claim to be inert", () => { + for (const t of LEGAL_TRIGGERS) { + if (t.preconditions.length > 0) { + expect(t.inertBecause, `${t.flag} is both gated and inert`).toBe( + undefined, + ); + } + } + }); + + test("every document a trigger names exists on disk", () => { + for (const t of LEGAL_TRIGGERS) { + for (const ref of t.documents) { + const file = ref.split("#")[0]!; + expect( + existsSync(join(REPO, file)), + `${t.flag} names ${file}, which does not exist`, + ).toBe(true); + } + } + }); + + test("attested preconditions name a checklist item and are self-consistent", () => { + const checklist = readReal("docs/compliance/publication-checklist.md"); + for (const t of LEGAL_TRIGGERS) { + for (const p of t.preconditions) { + if (p.kind !== "attested") continue; + expect(p.blocks, `${p.id} has no checklist reference`).toMatch( + /^[A-F]\d+$/, + ); + expect( + checklist.includes(`### ${p.blocks}.`) || + checklist.includes(`| ${p.blocks} `), + `${p.id} points at checklist item ${p.blocks}, which is not in the checklist`, + ).toBe(true); + // An attestation without a name is an unsigned one. + if (p.attestedOn === null) { + expect(p.attestedBy, `${p.id} has a signer but no date`).toBe(null); + } else { + expect(p.attestedOn).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(p.attestedBy, `${p.id} is attested but unsigned`).toBeTruthy(); + } + } + } + }); + + test("every requirement is a sentence somebody could act on", () => { + for (const t of LEGAL_TRIGGERS) { + for (const p of t.preconditions) { + expect( + p.requirement.length, + `${p.id} requirement is too terse`, + ).toBeGreaterThan(40); + } + } + }); +}); + +describe("every environment flag is mapped to a trigger", () => { + test("no *_ENABLED flag exists without a registry entry", () => { + // The catch for the feature nobody thought was a legal event. Parsing the + // schema rather than a hand-kept list means a new flag cannot be added + // without either mapping it or failing here. + const config = readReal("apps/bff/src/config.ts"); + const declared = [ + ...config.matchAll(/^ {2}([A-Z][A-Z0-9_]*_ENABLED):/gm), + ].map((m) => m[1]!); + expect(declared.length).toBeGreaterThan(0); + + const mapped = new Set(LEGAL_TRIGGERS.map((t) => t.flag)); + const unmapped = declared.filter((f) => !mapped.has(f)); + expect( + unmapped, + `these flags have no legal trigger entry: ${unmapped.join(", ")}. ` + + `Add one, marking it inert with a reason if switching it on creates ` + + `no obligation.`, + ).toEqual([]); + }); + + test("the registry does not map a flag that no longer exists", () => { + const config = readReal("apps/bff/src/config.ts"); + for (const t of LEGAL_TRIGGERS) { + expect( + config.includes(`${t.flag}:`), + `${t.flag} is mapped but is not declared in config.ts`, + ).toBe(true); + } + }); +}); + +describe("preconditions pass against the real repository", () => { + test.each(allCheckable.map((p) => [p.id, p] as const))( + "%s is satisfied by the documents as they stand", + (_id, p) => { + // Every capability is off here, so this proves the predicate does not + // fail a correct system. The mutation tests below prove it can fail. + const outcome = p.check( + ctx(baseConfig({ SEATGEEK_CLIENT_ID: "sg_client" })), + ); + expect(outcome.satisfied, outcome.satisfied ? "" : outcome.because).toBe( + true, + ); + }, + ); +}); + +describe("preconditions fail when the thing they protect is broken", () => { + // One case per checkable predicate. The `expectedFailures` list is compared + // against the registry at the end, so a predicate added without a case here + // fails the suite rather than going untested. + const cases: { + id: string; + context: TriggerContext; + }[] = [ + { + id: "events-credential-present", + context: ctx(baseConfig()), // SEATGEEK_CLIENT_ID unset + }, + { + id: "seatgeek-cap-is-fifty", + context: ctx( + baseConfig({ SEATGEEK_CLIENT_ID: "sg" }), + mutating( + "legal/terms-of-service.md", + /FIFTY UNITED STATES\s+DOLLARS \(USD 50\.00\)/, + "ONE HUNDRED UNITED STATES DOLLARS (USD 100.00)", + ), + ), + }, + { + id: "seatgeek-beneficiary-grant-intact", + context: ctx( + baseConfig({ SEATGEEK_CLIENT_ID: "sg" }), + mutating( + "legal/terms-of-service.md", + /section 13 \(limitation of liability\)/, + "section 12 (limitation of liability)", + ), + ), + }, + { + id: "privacy-disclaims-precise-location", + context: ctx( + baseConfig({ SEATGEEK_CLIENT_ID: "sg" }), + mutating("legal/privacy-policy.md", /city name only/, "coordinates"), + ), + }, + { + id: "musicbrainz-attribution-present", + context: ctx( + baseConfig(), + // Case-insensitive on purpose. The document carries 13 occurrences of + // "MusicBrainz" and 5 lowercase ones inside URLs, and the predicate + // matches case-insensitively because that is the right test for an + // attribution. Replacing only the exact-case occurrences left the + // predicate satisfied and this mutation proving nothing, which the + // suite caught. + mutating("legal/attribution.md", /MusicBrainz/gi, "SomeOtherDatabase"), + ), + }, + ]; + + test.each(cases.map((c) => [c.id, c] as const))( + "%s fails against a mutated input", + (id, c) => { + const p = allCheckable.find((x) => x.id === id); + expect(p, `no checkable precondition with id ${id}`).toBeDefined(); + const outcome = p!.check(c.context); + expect( + outcome.satisfied, + `${id} still reported success against a document that breaks it, ` + + `which means it checks nothing`, + ).toBe(false); + if (!outcome.satisfied) { + // A failure nobody can act on is barely better than no failure. + expect(outcome.because.length).toBeGreaterThan(40); + } + }, + ); + + test("every checkable precondition has a mutation case", () => { + const covered = new Set(cases.map((c) => c.id)); + const uncovered = allCheckable + .map((p) => p.id) + .filter((id) => !covered.has(id)); + expect( + uncovered, + `these predicates are never proved able to fail: ${uncovered.join(", ")}`, + ).toEqual([]); + }); +}); + +describe("enforcement", () => { + test("a capability that is off is not evaluated", () => { + // Failing a deployment over a precondition on something switched off would + // train people to delete the check rather than satisfy it. + const failures = evaluateLegalTriggers(ctx(baseConfig())); + expect(failures).toEqual([]); + expect(() => { + assertLegalTriggersSatisfied(ctx(baseConfig())); + }).not.toThrow(); + }); + + test("enabling events today is refused, because the consent gate does not exist", () => { + // This is the finding the whole registry exists to make mechanical. Three + // attested preconditions are unsatisfied, and the most important of them is + // that no client presents the documents, which on the day events are + // enabled breaches SeatGeek clause 4.3 in three places at once. + const config = baseConfig({ + SEATGEEK_ENABLED: "true", + SEATGEEK_CLIENT_ID: "sg_client", + }); + const failures = evaluateLegalTriggers(ctx(config)); + const ids = failures.map((f) => f.preconditionId); + expect(ids).toContain("consent-gate-presents-documents"); + expect(ids).toContain("documents-published-at-stable-url"); + expect(ids).toContain("counsel-confirmed-seatgeek-clauses"); + expect(() => { + assertLegalTriggersSatisfied(ctx(config)); + }).toThrow(/refusing to start/); + }); + + test("the refusal names the flag, the capability and what to do", () => { + const config = baseConfig({ + SEATGEEK_ENABLED: "true", + SEATGEEK_CLIENT_ID: "sg_client", + }); + let message = ""; + try { + assertLegalTriggersSatisfied(ctx(config)); + } catch (err) { + message = (err as Error).message; + } + expect(message).toContain("SEATGEEK_ENABLED"); + expect(message).toContain("Live event data from SeatGeek"); + expect(message).toContain("B6"); + expect(message).toContain( + "Turn the capability off, or satisfy the condition", + ); + }); + + test("a broken document fails a capability that is on", () => { + // The MusicBrainz mirror has one checkable precondition and no attested + // ones, so it is the clean case for proving that enabling something with a + // broken document is refused. + const config = baseConfig({ MB_LOCAL_ENABLED: "true" }); + expect(() => { + assertLegalTriggersSatisfied(ctx(config)); + }).not.toThrow(); + expect(() => { + assertLegalTriggersSatisfied( + ctx( + config, + // Case-insensitive on purpose. The document carries 13 occurrences of + // "MusicBrainz" and 5 lowercase ones inside URLs, and the predicate + // matches case-insensitively because that is the right test for an + // attribution. Replacing only the exact-case occurrences left the + // predicate satisfied and this mutation proving nothing, which the + // suite caught. + mutating( + "legal/attribution.md", + /MusicBrainz/gi, + "SomeOtherDatabase", + ), + ), + ); + }).toThrow(/musicbrainz-attribution-present/); + }); + + test("an inert capability can be switched on freely", () => { + expect(() => { + assertLegalTriggersSatisfied(ctx(baseConfig({ DOCS_ENABLED: "true" }))); + }).not.toThrow(); + }); +}); diff --git a/apps/bff/test/integration/legal-versions.test.ts b/apps/bff/test/integration/legal-versions.test.ts index 808dc87..51c634c 100644 --- a/apps/bff/test/integration/legal-versions.test.ts +++ b/apps/bff/test/integration/legal-versions.test.ts @@ -48,6 +48,7 @@ import { NON_CONSENT_LEGAL_FILES, PUBLISHED_DOCUMENTS, declaredHighlightSources, + HIGHLIGHT_DIGEST_PREFIX, declaredVersion, legalDigest, normalizeLegalText, @@ -212,15 +213,47 @@ describe("the consent screen copy", () => { `It must name every document its highlights quote, as \`id@version\`.`, ).not.toBeNull(); + // Every entry must carry a digest. The old `id@version` form parses to + // `digest: null`, and accepting it would restore the hole this closes. + const missingDigest = Object.entries(declared!) + .filter(([, v]) => v.digest === null) + .map(([id]) => id); + expect( + missingDigest, + `these entries pin only a version: ${missingDigest.join(", ")}. Write ` + + `them as \`id@version#digest\`, where digest is at least ` + + `${String(HIGHLIGHT_DIGEST_PREFIX)} hex characters of the source document's ` + + `content hash. A version alone does not move when an unpublished draft ` + + `is edited in place, which is how this interlock stayed green through ` + + `two rewrites of both source documents on 2026-07-30.`, + ).toEqual([]); + const expected = Object.fromEntries( - CONSENT_DOCUMENTS.map((doc) => [doc.id, doc.version]), + CONSENT_DOCUMENTS.map((doc) => [ + doc.id, + { + version: doc.version, + digest: doc.contentSha256.slice(0, HIGHLIGHT_DIGEST_PREFIX), + }, + ]), + ); + // Compare the declared prefix against the same length of the real digest, + // so a line written with a longer prefix is still accepted. + const normalised = Object.fromEntries( + Object.entries(declared!).map(([id, v]) => [ + id, + { + version: v.version, + digest: (v.digest ?? "").slice(0, HIGHLIGHT_DIGEST_PREFIX), + }, + ]), ); expect( - declared, + normalised, `\n\n${CONSENT_PRESENTATION.path} quotes figures and section numbers out of ` + `the documents below, and the versions it was checked against are no longer ` + `the current ones.\n\n` + - ` declared: ${JSON.stringify(declared)}\n` + + ` declared: ${JSON.stringify(normalised)}\n` + ` current: ${JSON.stringify(expected)}\n\n` + `Do NOT just update the line. Re-read section 3.1 of ` + `${CONSENT_PRESENTATION.path} against the new text, then decide:\n\n` + @@ -245,6 +278,70 @@ describe("the consent screen copy", () => { }); }); +describe("the highlight pin parser", () => { + // The interlock above passes against the real file, which proves only that it + // does not fail a correct repository. These prove it can fail, which is the + // half that makes it worth having. + const pin = (body: string): string => + `# X\n\n**Highlights checked against:** ${body}\n\nbody\n`; + + test("extracts the digest from the id@version#digest form", () => { + const got = declaredHighlightSources( + pin("`terms-of-service@DRAFT-1#180d130e2d6f`"), + ); + expect(got).toEqual({ + "terms-of-service": { version: "DRAFT-1", digest: "180d130e2d6f" }, + }); + }); + + test("reports a digest-less entry as such rather than accepting it", () => { + // The old form. Parsing it to `digest: null` is what lets the interlock + // reject it by name; silently treating it as satisfied would reopen the + // hole, because an unpublished draft edited in place keeps its version. + const got = declaredHighlightSources(pin("`terms-of-service@DRAFT-1`")); + expect(got).toEqual({ + "terms-of-service": { version: "DRAFT-1", digest: null }, + }); + }); + + test("the version stops at the hash, so neither field absorbs the other", () => { + const got = declaredHighlightSources( + pin("`privacy-policy@DRAFT-1#f18244518ebd`"), + ); + expect(got!["privacy-policy"]!.version).toBe("DRAFT-1"); + expect(got!["privacy-policy"]!.digest).toBe("f18244518ebd"); + }); + + test("a stale digest is distinguishable from a current one", () => { + // The exact scenario this closes: same version, different bytes. + const current = CONSENT_DOCUMENTS.find((d) => d.id === "terms-of-service")!; + const stale = declaredHighlightSources( + pin("`terms-of-service@DRAFT-1#000000000000`"), + ); + expect(stale!["terms-of-service"]!.version).toBe(current.version); + expect(stale!["terms-of-service"]!.digest).not.toBe( + current.contentSha256.slice(0, HIGHLIGHT_DIGEST_PREFIX), + ); + }); + + test("the declared prefix is long enough to mean something", () => { + // A two-character prefix would collide constantly and the interlock would + // pass through most real edits. + expect(HIGHLIGHT_DIGEST_PREFIX).toBeGreaterThanOrEqual(8); + const declared = declaredHighlightSources(read(CONSENT_PRESENTATION.path))!; + for (const [id, v] of Object.entries(declared)) { + expect(v.digest, `${id} pins no digest`).not.toBeNull(); + expect(v.digest!.length).toBeGreaterThanOrEqual(HIGHLIGHT_DIGEST_PREFIX); + } + }); + + test("absent line is null, not an empty object", () => { + // An empty object would compare equal to nothing and make the interlock + // silently vacuous; null lets the test say the line is missing. + expect(declaredHighlightSources("# X\n\nno pin here\n")).toBeNull(); + }); +}); + describe("normalisation", () => { const sample = "# Title\n\nA line with trailing space \nAnother line\n"; diff --git a/apps/bff/test/integration/recurring-audits.test.ts b/apps/bff/test/integration/recurring-audits.test.ts new file mode 100644 index 0000000..72e68b2 --- /dev/null +++ b/apps/bff/test/integration/recurring-audits.test.ts @@ -0,0 +1,155 @@ +/** + * The recurring audits are performed, and the check that says so can fail. + * + * The overdue arithmetic takes `now` as a parameter precisely so it can be + * tested against fixed dates. A date-based check exercised only on the day the + * suite happens to run is not a test; it is a coincidence. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, test } from "vitest"; + +import { + RECURRING_AUDITS, + describeOverdue, + dueDate, + overdueAudits, + type RecurringAudit, +} from "../../src/lib/recurring-audits.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = join(HERE, "..", "..", "..", ".."); + +const sample: RecurringAudit = { + id: "sample", + what: "Re-read a thing that changes without telling us", + why: "Because it changes without telling us", + intervalDays: 90, + lastCompletedOn: "2026-01-01", + lastCompletedBy: "operator", + evidence: "somewhere", + blocks: "B8", +}; + +describe("the overdue arithmetic", () => { + test("computes the due date from the interval", () => { + expect(dueDate(sample)).toBe("2026-04-01"); + }); + + test("an audit inside its interval is not overdue", () => { + expect(overdueAudits(new Date("2026-03-31T00:00:00Z"), [sample])).toEqual( + [], + ); + }); + + test("an audit exactly on its due date is not yet overdue", () => { + // The boundary matters: firing on the due date would make every interval + // one day shorter than it says, and a check whose stated interval is not + // its real interval is a small lie that compounds. + expect(overdueAudits(new Date("2026-04-01T00:00:00Z"), [sample])).toEqual( + [], + ); + }); + + test("an audit past its due date IS overdue, with the day count", () => { + const overdue = overdueAudits(new Date("2026-04-11T00:00:00Z"), [sample]); + expect(overdue).toHaveLength(1); + expect(overdue[0]!.daysOverdue).toBe(10); + expect(overdue[0]!.dueOn).toBe("2026-04-01"); + }); + + test("the failure text says what to do, not just what is wrong", () => { + const text = describeOverdue( + overdueAudits(new Date("2026-04-11T00:00:00Z"), [sample]), + ); + expect(text).toContain("What:"); + expect(text).toContain("Why it matters:"); + expect(text).toContain("To clear:"); + expect(text).toContain("fix anything it finds FIRST"); + }); +}); + +describe("the registry is well formed", () => { + test("ids are unique", () => { + const ids = RECURRING_AUDITS.map((a) => a.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("dates are ISO and not in the future", () => { + // A future completion date would silently extend every interval. + const today = new Date().toISOString().slice(0, 10); + for (const a of RECURRING_AUDITS) { + expect(a.lastCompletedOn, a.id).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect( + a.lastCompletedOn <= today, + `${a.id} claims to have been completed on ${a.lastCompletedOn}, which ` + + `is in the future`, + ).toBe(true); + } + }); + + test("intervals are sane", () => { + for (const a of RECURRING_AUDITS) { + expect(a.intervalDays, a.id).toBeGreaterThanOrEqual(7); + expect(a.intervalDays, a.id).toBeLessThanOrEqual(365); + } + }); + + test("each names a checklist item that exists", () => { + const checklist = readFileSync( + join(REPO, "docs/compliance/publication-checklist.md"), + "utf8", + ); + for (const a of RECURRING_AUDITS) { + expect(a.blocks).toMatch(/^[A-F]\d+$/); + expect( + checklist.includes(`### ${a.blocks}.`) || + checklist.includes(`| ${a.blocks} `), + `${a.id} points at checklist item ${a.blocks}, which is not in the checklist`, + ).toBe(true); + } + }); + + test("each names evidence that exists, where the evidence is a file", () => { + for (const a of RECURRING_AUDITS) { + const file = a.evidence.split(" ")[0]!; + if (!file.includes("/")) continue; + expect( + existsSync(join(REPO, file)), + `${a.id} cites evidence at ${file}, which does not exist`, + ).toBe(true); + } + }); + + test("the why is specific enough to justify the interruption", () => { + // A build that fails on a date has to explain itself or it gets suppressed. + for (const a of RECURRING_AUDITS) { + expect(a.why.length, `${a.id} why is too vague`).toBeGreaterThan(80); + expect(a.what.length, `${a.id} what is too vague`).toBeGreaterThan(80); + } + }); +}); + +describe("nothing is currently overdue", () => { + test("every recurring audit is within its interval", () => { + const overdue = overdueAudits(new Date()); + expect( + overdue.length, + `\n\nA recurring legal audit is overdue. These lapse with time rather ` + + `than with a code change, so nothing else in this repository will ` + + `notice.\n\n${describeOverdue(overdue)}\n`, + ).toBe(0); + }); + + test("that assertion is not vacuous", () => { + // Proves the registry is non-empty and the checker returns findings for a + // date far enough forward, so a green result above means "all current" + // rather than "nothing configured". + expect(RECURRING_AUDITS.length).toBeGreaterThan(0); + const farFuture = new Date("2099-01-01T00:00:00Z"); + expect(overdueAudits(farFuture)).toHaveLength(RECURRING_AUDITS.length); + }); +}); diff --git a/docs/compliance/publication-checklist.md b/docs/compliance/publication-checklist.md new file mode 100644 index 0000000..c72f123 --- /dev/null +++ b/docs/compliance/publication-checklist.md @@ -0,0 +1,405 @@ +# Publication checklist for the legal documents + +Everything here blocks publication of `legal/privacy-policy.md` or +`legal/terms-of-service.md` at a stable URL. It is the internal companion to +those documents: the drafting rationale, the unresolved questions, and the +evidence trail behind claims the documents state flatly. + +**Why this file exists separately.** A privacy policy states obligations. It does +not narrate why it states them, and it does not carry a running commentary on its +own drafting. Both documents previously did, which made them read as engineering +memoranda about law rather than as legal instruments. The reasoning was not +discarded when they were rewritten on 2026-07-30; it moved here. Where a +disclosure qualified something a user is told, it stayed in the document. + +**The rule that governs this split.** A limitation that changes what a user can +rely on belongs in the published document, however uncomfortable. A limitation +that only concerns whether the operator has finished their homework belongs here. +Deleting a disclosure of the first kind to make a document read more smoothly +turns a disclosed weakness into a false statement, which is precisely what +`legal/check-publication-blockers.mjs` exists to prevent. + +--- + +## A. Blocking, and disclosed in the published documents + +These remain visible to the reader because each qualifies something the reader is +otherwise entitled to rely on. Closing them means changing the system, not the +sentence. + +| # | Item | Where disclosed | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| A1 | No retention period is configured for application or web server logs anywhere in the system. A number must exist before one is stated. | Privacy policy section 8 | +| A2 | The retention jobs are implemented, tested and scheduled by committed systemd timers, and **no scheduled run has ever fired**, because no compute is deployed. Every period in section 8 is therefore applied on execution rather than automatically. Closing this requires evidence that a timer fired, not further configuration. | Privacy policy section 8 | +| A3 | The 35-day and 90-day object-storage lifecycle rules are configured but **cannot be read by us**. The backup credential was deliberately narrowed to a bucket-scoped object token after an account-wide grant was found to read every bucket in the estate; lifecycle reads are a bucket-admin operation and that token is refused them. Run the conformance check from an operator credential. | Privacy policy section 7.2 and section 8 | +| A4 | WorkOS does not publish the schedule on which its backup and archival copies of identity data are deleted following termination. | Privacy policy section 8 | +| A5 | Custody of the application encryption key rests with a **single holder**, the operator (`PULLFM-RISK-003`). | Privacy policy sections 5 and 12 | +| A6 | Other than California and Connecticut, the state comprehensive privacy statutes have not been checked against primary sources. The stated position is the operator's understanding. Re-surveying them is a recurring audit with a trigger (`apps/bff/src/lib/recurring-audits.ts`), because these statutes change on their own schedule: Connecticut removed two applicability thresholds on 1 July 2026 and nothing here would have surfaced it. | Privacy policy section 10.2 | + +--- + +## B. Blocking, and deliberately internal + +These do not change what a user may rely on. They are the operator's unfinished +work, and stating them in a published legal instrument would be a category error. + +### B1. Whether listening history is "sensitive data" under the CTDPA + +**This is the most consequential open question in the file.** Public Act 25-113 +took effect on 1 July 2026 and removed the volume threshold from two of the three +CTDPA applicability triggers. Pull.fm's position now rests on processing no +sensitive data rather than on being small, and that position is only as strong as +the answer to one question. + +The CTDPA definition reaches personal data _revealing_ religious beliefs or +sexual orientation. Privacy policy section 3.6 states that musical taste can be +revealing, which is true and is the reason the point is arguable. No United +States court has decided whether data permitting an inference of that kind is +"revealing" data within such a definition. Under the GDPR the Court of Justice +took the broad view in `C-184/20 (OT v Vyriausioji tarnybinės etikos komisija)`, +holding that data capable of indirectly disclosing sexual orientation falls +within the special categories. + +If listening history is sensitive data under the CTDPA, the second trigger +attaches with no threshold to escape, and **the relevant event is the first +Connecticut user rather than the first paying user.** + +Counsel must reach a view before publication. + +### B2. Massachusetts and New York citations + +The duty in privacy policy section 9.3 to require security measures of a service +provider by contract is sourced to **Massachusetts 201 CMR 17.03(2)(f)** and the +**New York SHIELD Act**. Both citations are the operator's reading and have not +been checked against primary sources. Four processor records rest on them. +Counsel must confirm both, and whether any other state imposes the same duty. + +### B3. State-by-state breach notification deadlines + +Privacy policy section 13 states the structure of the obligation and the position +we would take. The precise deadlines per state, and which of them treat the +information Pull.fm holds as covered, have not been compiled. The commitment to +notify does not depend on that work; the accuracy of any specific deadline does. + +### B4. Infrastructure not pinned by committed code + +Two placements in privacy policy section 9.2 are held by something less durable +than the rest, and the document says so without elaborating: + +- The application node's location is supplied from an operator file that is not + committed, while the committed default for that variable is still a European + site. A fresh apply that omitted the operator file would place the node in + Europe. +- The erasure-ledger bucket is not described by infrastructure code at all, so + unlike the backup bucket there is no committed artifact fixing or checking its + placement. + +Neither makes a statement in section 9.2 false today. Both mean a true statement +is one forgotten input away from becoming false. + +### B5. Postal address + +No postal address is published, and section 2 of the privacy policy records what +was checked. Two future events reverse the decision and are named there: entering +an app store, and sending a first commercial email. Separately, 312.dev LLC's +registered agent address is already public record at the Illinois Secretary of +State, so publishing it would cost nothing in privacy terms. Whether to publish +it anyway is an operator decision that has not been made. + +### B6. No client presents the documents, so no assent is obtained + +**This is the largest gap in the project and it is worth more than any clause in +either document.** Distribution is a sideloaded application file from GitHub +Releases, so there is no store flow and no installer dialogue in which a user is +shown terms and acts on them. + +_What exists._ Both documents carry machine-readable versions and a content +digest. The API records who accepted which version of which document, when, from +which session and on which client build, in an append-only table an UPDATE cannot +rewrite. A material revision raises a consent epoch and every user must accept +again; a corrected typo does not. `GET /v1/me/consent` reports what an +authenticated user still owes and `POST /v1/me/consent` records an acceptance. A +user who has accepted nothing is refused every route except signing out, reading +their own account, the consent endpoints, and export and deletion, which are never +conditioned on accepting the Terms. A user who accepted an earlier epoch keeps +read access and is refused writes. The record is server-side and survives a +reinstall. + +_What does not exist._ No client presents the documents. A server that records an +acceptance it was told about cannot know a human was shown anything, and under +Illinois law the interface is the whole question. Two prerequisites are also +outstanding: the documents are not published at a stable URL, and the client must +fetch the canonical document bytes and echo their digest, because the API refuses +an acceptance whose digest does not match the version it publishes. Publishing a +rendered page whose bytes differ from the canonical source would make acceptance +impossible rather than merely inconsistent. + +_Why it matters more than it appears._ In `Sgouros v. TransUnion Corp.`, 817 F.3d +1029 (7th Cir. 2016), no contract formed even where the user completed a paid +purchase on a page displaying the terms, because the interface did not +communicate that proceeding was assent. A sideloaded application with no consent +step has a weaker record than that. **If no contract forms, nothing binds** - +not the limitation of liability, not the Illinois governing-law and venue +selection, not the third-party beneficiary grant, and not the US-only offering. + +_The second, independent reason._ SeatGeek clause 4.3 does not merely require +that an Application EULA exist. It requires that the Application **displays** it +and that each End User be **required to accept it before using the Application**, +and it separately obliges us to use all reasonable efforts to enforce it and to +ensure the Application collects information from an End User only where that End +User has affirmatively authorised it. **On the day live events are enabled, the +missing screen is a breach of the SeatGeek agreement in three places at once**, +and the affirmative-authorisation duty is not limited to SeatGeek data. Enabling +events without it is worse than leaving events disabled. + +The recording half is what makes 4.3(i) dischargeable at all: "use all reasonable +efforts to enforce" needs a system that knows who accepted which version and +refuses service to someone who has accepted nothing. What that system cannot do +is manufacture the acceptance it records. + +### B7. SeatGeek third-party beneficiary and liability cap: counsel questions + +Terms sections 9 and 13 must be confirmed by counsel as drafted so as to be +effective under Illinois law, and as satisfying "at least as protective of the +SeatGeek Entities as the terms hereof" under SeatGeek clause 4.3 through the +combination of sections 7, 8, 9, 11, 13 and 14. Three specific questions: + +1. Whether the USD 50 cap in the fourth bullet of section 13 discharges 4.3's + express reference to "limitations of liability", given that SeatGeek's own + clause 8.2 caps them at exactly USD 50, so we match rather than better it. +2. Whether "at least as protective" reaches **conspicuousness** as well as + substance. Their 8.2 is in capitals and ours now is for the SeatGeek bullet + only. Under Illinois law a limitation of liability must be conspicuous to be + enforceable at all. +3. Whether sections 9 and 13 together survive `Sosa v. Onfido`, 8 F.4th 631 (7th + Cir. 2021), which turned on a third party falling outside the defined class the + limitation protected. Onfido could not enforce an app operator's terms because + the limitation protected "OfferUp providers", defined as "affiliates [and] + licensors", and the court held Onfido was neither. Illinois requires that + third-party benefit be practically an express declaration. + +**Do not consolidate the SeatGeek bullet into the general cap.** A single bullet +reading "USD 100, or USD 50 for the SeatGeek Entities" is the same sentence and a +worse one: protection extended to a third-party beneficiary should be findable by +searching for that beneficiary's name. The enumeration in section 9 must also +name section 13 and not section 12; a mislabelled cross-reference in the clause +doing the extending is exactly the `Sosa` defect. + +### B8. The SeatGeek cap moves downward only, without notice + +The USD 50 figure was USD 100 until 2026-07-29, which was a breach of the very +clause these Terms exist to satisfy: our cap extended "the same limitations" to +the SeatGeek Entities by name, so the number protecting them was USD 100, twice +the exposure their own clause 8.2 permits. It was invisible because every prior +analysis in this repository worked from a paraphrase in which 8.2 was mis-numbered +as "9.2" and reduced to a fact about their risk appetite rather than a ceiling on +ours. + +Section 1 of SeatGeek's terms permits them to change their terms at any time with +continued use as acceptance. **If their cap on themselves drops below USD 50, +this bullet is immediately non-compliant with no notice to us.** Re-audit +quarterly against +[`../../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md`](../../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md). + +**The quarterly re-audit has a trigger and is no longer an intention.** +`apps/bff/src/lib/recurring-audits.ts` records when it was last performed and by +whom, and the suite fails once it is overdue. Clearing it means doing the audit +and recording the date, not dismissing a reminder. Note the division of labour: +the `seatgeek-cap-is-fifty` predicate in `legal-triggers.ts` catches **our** +figure moving, and only this audit can catch **theirs**. + +### B9. Arbitration: the decision and the premises that would reverse it + +Decided 2026-07-29 by the controller: **no arbitration clause and no class-action +waiver.** Recorded so the decision is re-examined on its premises rather than +re-argued from scratch. + +- **The forum costs more than the liability it would protect.** The cap is USD 100. Business-side arbitration fees exceed that by roughly 12x to 35x at every + scale, and there is no number of claimants at which arbitrating is cheaper than + paying each claimant the cap. AAA also charges an annual consumer-clause + registry fee in perpetuity whether or not anyone files. +- **The Illinois-specific reason does not apply.** BIPA carries a private right + of action and a class-action waiver is the standard defence, but BIPA section 10 + is a closed list of biometric identifiers that expressly excludes photographs, + and every section 15 duty is conditioned on collecting or possessing one. This + service collects none. +- **The external pressure is absent.** SeatGeek clause 12.2 selects New York law + and the exclusive venue of New York County and contains no arbitration clause + and no class-action waiver, so the "at least as protective" requirement does not + import one. + +**Re-open if either premise changes.** If a feature ever touches voice, face or +fingerprint data, BIPA attaches and the calculation inverts. If the service ever +charges money, the cap and the cost-benefit both move. Adding a clause later binds +only users who accept the amended terms, so this is cheap now and expensive to +reverse. If one is ever added, name **JAMS** and expressly invoke its Mass +Arbitration Procedures, which charge a flat filing fee regardless of case count, +rather than AAA, which charges per case and scales linearly against the +defendant. Include an express bar on class arbitration and state that the waiver +is non-severable, because `Kinkel` severed a waiver and enforced the clause +without it, sending the defendant into class arbitration. + +### B10. Counsel review + +Neither document has been reviewed by a lawyer. Both must be before publication +at a stable URL. + +--- + +## C. Constraints on future work + +Not blocking today. Each becomes a false statement the moment the corresponding +feature ships, so each is recorded as a constraint on what may be built rather +than as a task. + +| # | Constraint | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| C1 | If a release stores per-user listening history, ranking state or an inferred taste profile, privacy policy section 3.3 must be rewritten **before it ships**, and those rows must cascade on user deletion, or the deletion claim in section 7 becomes false. A user-scoped `upstream_cache` key would place user-linked rows in a table the deletion cascade does not touch. | +| C2 | If a feature ever uses voice, face, fingerprint or anything else within the Illinois Biometric Information Privacy Act, the Act's written notice, written release and published retention schedule attach **before the first collection**. There is no version of such a feature that can ship first and be papered afterwards. | +| C3 | If social sign-in, passkeys, WebAuthn or passwords are ever enabled, privacy policy section 3.1 must change, because the provider would then also disclose which third-party account was used. A database constraint on `users.auth_method` and a test currently prevent this from happening silently. | +| C4 | Taking revenue, or reaching 100,000 users, converts section 10.2 from "below the threshold" to "covered". The obligations that arrive are not paperwork: a notice at collection, verified consumer request handling with statutory deadlines, and contractual terms with every service provider. **Treat the first paying user and the hundred-thousandth account as legal events.** | +| C5 | If Pull.fm ever sells or shares personal information, honouring Global Privacy Control becomes an engineering obligation rather than a sentence, because the signal must be detected and acted on in code. | +| C6 | Entering an app store makes a postal address mandatory. Sending a first commercial email engages 15 U.S.C. section 7704(a)(5)(A)(iii). | + +--- + +## D. Evidence behind claims the documents state flatly + +Recorded so that a reader of the documents does not have to take them on trust, +and so that a later editor does not weaken a sentence that was expensive to earn. + +**The replay list cannot live in the database.** A restore drill on 2026-07-29 +disproved the previous design directly: erasing an account after a restore point +and then restoring to before it left the account present and `deletion_log` +holding zero rows. The list lived inside the thing being rolled back, so the +rollback took the evidence with it. The ledger now lives in object storage as one +immutable object per erasure, written inline with the deletion before anything is +destroyed. Append-only is enforced by a retention lock on the bucket rather than +by the credential, because the platform has no write-only permission; a delete +and an overwriting write were both attempted and both refused. The honest limit: +the lock is administered by the same account that administers everything else, so +it defends the ledger against a compromised application and not against a +compromised account. + +**The ledger has its own bucket.** Object-storage credentials scope to a bucket +and never to a key prefix, so a credential permitting the API to write ledger +entries inside the backup bucket would also permit a compromised API to destroy +every backup. + +**Audit anonymisation uses a random pseudonym, not a keyed hash.** Because +`deletion_log` permanently retains the identifier of every deleted account, a +keyed scheme would be reversible by anyone holding both the key and that table. A +fresh random UUID minted inside the applying statement has no such property. + +**Daily dumps follow from the retention figure.** What can be restored to is the +window divided by the interval, so daily gives roughly 35 recovery points inside +35 days where weekly would give five. + +**The scheduled dump excludes the imported MusicBrainz catalogue** and retains its +schema. That table is an import of a published upstream dataset that a committed +command rebuilds, and at 31.5 million rows it was 99.99% of the database by size. +Every manifest lists what was excluded. Nothing a user supplied is excluded. + +**Backups are not selectively rewritten.** This is the position the ICO, the EDPB +and every serious analysis of the erasure right take: selectively rewriting a +backup destroys the integrity that makes it a backup, and the attempt would be a +larger risk to every other user's data than the residual retention is to the +deleted one. It is an argument about what a backup is and does not change with +the jurisdiction. + +**The seven-day recovery window replaced six hours** when the database moved to a +paid United States plan. The number moved because the plan and the project moved, +not because anyone tuned it. It is stated in the direction that matters to a user: +a longer recovery window means deleted data remains restorable for longer. + +**The CCPA revenue threshold is quoted from the statute** rather than as the +current adjusted figure, because the adjustment under section 1798.199.95 is +automatic and a number written into the document would go stale without anyone +noticing. The figure in force at the time of writing is $26,625,000. + +**Continued use is not acceptance.** Inferring assent from continued use is the +arrangement that failed in `Sgouros v. TransUnion Corp.`, 817 F.3d 1029 (7th Cir. +2016), which is Seventh Circuit law over Illinois. A court declined to find a +contract there even though the terms were displayed and the user completed a paid +purchase, because the interface did not communicate that proceeding was assent. +Continued use communicates less than that, not more. It would also understate +what the service does, which is to stop accepting changes and ask. + +**SeatGeek's terms were read in a browser, not fetched.** Every automated request +to `seatgeek.com` returns 403, including its public press page, which is blanket +bot-blocking rather than a login wall. The clauses were transcribed by hand and +the verbatim text is at +[`../../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md`](../../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md) +so the quotation in the terms of service can be checked against its source. Their +clause 12.2 selects New York law and the exclusive venue of New York County, and +contains no arbitration clause and no class-action waiver, so the "at least as +protective" requirement in their clause 4.3 does not import one. + +--- + +## F. What the published documents describe, in implementation terms + +The privacy policy was written against the schema, the export builder, the +deletion cascade, the logger redaction list and the infrastructure definitions +rather than from a template. Until 2026-07-30 it named the tables and columns +directly, which made every claim checkable and made the document read as a +schema description. **The names moved here and the claims stayed there.** + +**Why the names could not stay.** A privacy policy is a representation under +section 5 of the FTC Act. A document that asserts `users.auth_method` constrains +sign-in becomes a false statement the moment somebody renames the column, with no +lawyer in the loop and no test that would catch it. Here, a stale name is a +documentation defect. There, it was a misrepresentation. Verifiability was the +right instinct; the published legal instrument was the wrong place for it. + +**This table is enforced, not merely maintained.** +`apps/bff/test/integration/legal-claims.test.ts` parses it, classifies every +backticked token, and asserts that each file, environment variable, package +script, table and column named here actually exists. Renaming +`users.auth_method` fails a test that names the policy sentence depending on it. +The suite also refuses to pass if fewer than fifteen tokens resolve, so emptying +this table or breaking the parser is itself a failure rather than a quiet +reduction in coverage. + +| Policy statement | Implementation | +| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| "A stable account identifier" (3.1) | `users.workos_user_id` | +| "Your email address" (3.1) | `users.email`, lower-cased | +| "A display name" (3.1) | `users.display_name` | +| "Enforced in our systems rather than left to convention" (3.1) | Check constraint on `users.auth_method`, plus a test failing if any password, social, passkey or SSO route is registered | +| Connected services (3.2) | `user_connections`; credentials in `access_token_ct` and `refresh_token_ct`; username in `provider_account_id` | +| "A shared cache organised by service and content, carrying no account identifier" (3.3) | `upstream_cache`, no user column. **A user-scoped key here would place user-linked rows in a table the deletion cascade does not touch.** See C1. | +| "Recommendations and the personalised feed are not yet available" (3.3) | Those routes return HTTP 501 | +| Wishlist entries (3.4) | `wishlist_items` | +| Personal API tokens (3.4) | `api_tokens`; "irreversible fingerprint" is a SHA-256 digest | +| "A record of each change you make" (3.5) | `idempotency_keys` | +| Security records (3.5) | `audit_log`; the closed event list is defined in `apps/bff/src/lib/audit.ts` | +| "A record that a deletion occurred" (3.5, 7.1) | `deletion_log`. Deliberately has no foreign key to `users`, so rows outlive the deletion they record. | +| "Short-lived counters" (3.5) | Redis, both instances | +| "Web server access logs" (3.5) | nginx on the origin node | +| "Encrypted using AES-256, under a key unique to that record" (5) | AES-256-GCM envelope encryption; per-record data key wrapped by an application-wide KEK held outside the database | +| "Bound to your account, the service and the specific field" (5) | Additional authenticated data covers user, provider and column | +| Export and deletion (6, 7, 10.1) | `GET /v1/me/export` and `DELETE /v1/me` | +| "Removed together, in a single step that either completes or does not happen at all" (7) | One `DELETE FROM users` in a transaction; every user-owned table declares `ON DELETE CASCADE`; asserted by `packages/db/scripts/verify-migrations.mjs` on every CI run | +| "Signed in within the previous fifteen minutes" (7) | `DELETE_FRESH_AUTH_MAX_AGE_S`, default 900 | +| "Your request fails and nothing is deleted" (7.2) | The route returns HTTP 503 if the erasure-ledger write fails | +| "Reduced to its general network area" (7.1) | `/24` for IPv4, `/48` for IPv6, overwritten in place | +| "An untraceable substitute" (7.1) | A random UUID minted inside the applying statement, once per account per batch. See section D for why not a keyed hash. | +| "Recovery to any point within the previous seven days" (7.2) | `history_retention_seconds = 604800` in `infra/neon/variables.tf` | +| "Deleted automatically on expiry" (8, unverified sign-in records) | `AUTH_UNVERIFIED_REAP_AFTER_S`, applied by `reap:unverified` | +| The three retention processes (8) | `purge:audit`, `sweep:expired`, `reap:unverified`, each with a committed systemd timer and a CI check asserting the units are enabled and expand to the documented intervals | +| "A United States region" (9.2) | `aws-us-east-1` | +| "Settings that are not part of our published configuration" (9.2) | The node location variable, whose committed default is still a European site, and the erasure-ledger bucket, which infrastructure code does not describe. See B4. | +| "Automated tests that fail our build if any is left untested" (12) | The route authorization suite | + +--- + +## E. Closed + +| Item | Resolution | +| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Registration refusal for the EEA, UK and Switzerland did not exist | Resolved 2026-07-29. `apps/bff/src/lib/registration-geo.ts` holds the list, three auth routes enforce it before the identity provider is called, 40 tests across two suites cover it. Its limits are disclosed in privacy policy section 9.1. | +| Controller's state of organisation unfilled | Resolved. 312.dev LLC is organised in Illinois. | +| No written security commitments on file with Neon or Hetzner | Resolved 2026-07-30. All four agreements are in writing and filed as dated documents in the operator's vault, addressed by title, deliberately not copied into this public repository. What remains is B2, the citations they rest on. | +| Terraform described the pre-Neon self-hosted database | Resolved. Region confirmed by reading the live project from the provider API. | +| Sign-in methods sentence disagreed with the plan | Resolved 2026-07-29. Magic link only, enforced by a database constraint and a test. | diff --git a/legal/consent-presentation.md b/legal/consent-presentation.md index c4b8b84..fa47ced 100644 --- a/legal/consent-presentation.md +++ b/legal/consent-presentation.md @@ -49,7 +49,7 @@ **Version:** DRAFT-0 (unpublished) **Last updated:** 2026-07-30 **Effective:** not yet effective -**Highlights checked against:** `terms-of-service@DRAFT-1`, `privacy-policy@DRAFT-1` +**Highlights checked against:** `terms-of-service@DRAFT-1#180d130e2d6f`, `privacy-policy@DRAFT-1#f18244518ebd` The `Highlights checked against` line above is **machine-checked**, not decorative. `apps/bff/test/integration/legal-versions.test.ts` compares it to the @@ -59,6 +59,15 @@ numbers out of the Terms; without that line, a Terms revision that moved the liability cap or renumbered a section would leave this screen stating a false figure to every new user, with nothing to catch it. See section 2.3. +**The pin carries a content digest as well as a version, and the version alone +was not enough.** A version only moves when somebody decides to move it, and an +unpublished draft is edited in place: redefining a version nobody has ever been +served is not a revision. Both source documents were rewritten twice on +2026-07-30 while remaining `DRAFT-1`, and this interlock stayed green throughout, +certifying as re-read a set of figures sitting in text nobody had re-read. The +digest closes that: any change to either document's bytes turns this file red, +whether or not its version moved. + --- ## 1. What this document is, and what it deliberately is not diff --git a/legal/privacy-policy.md b/legal/privacy-policy.md index 3146984..063dd44 100644 --- a/legal/privacy-policy.md +++ b/legal/privacy-policy.md @@ -1,55 +1,11 @@ # Pull.fm Privacy Policy -> # DRAFT - NOT LEGAL ADVICE, REQUIRES REVIEW +> **DRAFT. Not yet effective. Has not been reviewed by counsel.** > -> Every factual claim below was written by reading the code, not from a -> template: the schema in [`../packages/db/migrations/`](../packages/db/migrations/), -> the export builder, the deletion cascade, the logger redaction list, and the -> Terraform that decides where the data physically sits. Where the code does not -> yet support a claim, this document says so instead of making it. -> -> **It has not been reviewed by a lawyer.** It must be before it is published at -> a stable URL. -> -> **Items marked `[OPEN]` are gaps in the system, not gaps in the writing.** A -> privacy policy that describes a system the code does not implement is a false -> statement of fact, which is worse than having no policy. Each `[OPEN]` must be -> closed in code, or the surrounding sentence must be rewritten to match reality, -> before this is published. -> -> **Items marked `[CONFIRM]` need an operator or counsel decision.** -> -> **Before publishing, run `make legal`** (or -> `node legal/check-publication-blockers.mjs`). It exits non-zero while any -> marker remains, prints each with a file and line, and flags the ones that are -> void or misleading rather than merely incomplete. -> -> **This document was rewritten on 2026-07-29 for a United States posture.** The -> earlier version was written for a service offered in the European Union, and -> the GDPR analysis in it is gone rather than softened. Section 9 states the -> basis for that, section 10 states which United States laws were checked and -> which of them Pull.fm is below the threshold of, and neither claim is made -> without saying how it was reached. -> -> **Both of the changes the posture depends on have now landed.** Registration -> from the EEA, the United Kingdom and Switzerland is refused by code, covered by -> tests, and described in section 9 including what it cannot do. **The -> infrastructure moved on 2026-07-29**: the database is a United States Neon -> project, the application node is in Ashburn, Virginia, and the entire European -> estate was deleted rather than left running. Section 9 states each placement -> separately and was re-read against the live provider APIs rather than against -> the intention. -> -> **One consequence of that move is a weaker claim than the move suggests, not a -> stronger one.** Object storage is **not pinned to any jurisdiction**, because -> the platform offers no United States jurisdiction to pin it to. Section 9 says -> so plainly and declines to say "stored in the United States", which would be -> the comfortable sentence and the false one. -> -> 312.dev LLC is organised in **Illinois**, which the operator supplied on -> 2026-07-29. That settles the controller identity here and the governing law and -> venue in `terms-of-service.md` section 16. **The postal address is still -> unfilled** and is a separate fact. +> This policy has not been published at a stable URL and no user has accepted it. +> Outstanding items are tracked in +> [`../docs/compliance/publication-checklist.md`](../docs/compliance/publication-checklist.md) +> and must be closed before publication. **Version:** DRAFT-1 (unpublished) **Last updated:** 2026-07-30 @@ -59,1144 +15,585 @@ ## 1. Summary -- Pull.fm is **free and non-commercial**. We do not sell, rent, or share your - personal information, and there is no advertising, no ad tech, and no - affiliate revenue anywhere in the product. -- We have **no analytics, no tracking pixels, no advertising SDK, and no - third-party telemetry or error-reporting service**. This is verifiable: the - repository is public and contains no such dependency. -- We collect the **minimum needed to run a music discovery service**: an - identifier and email from your sign-in provider, the third-party music - accounts you choose to connect, what you put on your wishlist, and operational - logs. -- Your **connected-service credentials are encrypted at rest** with per-record - keys and are never exported, logged, or shown to anyone, including us in the - ordinary course of operating the service. -- **Pull.fm is offered to residents of the United States.** Registration from the - European Economic Area, the United Kingdom and Switzerland is refused. Section - 9 states what that means for which laws apply, and says plainly which part of - it is built and which part is not yet. -- **Where your data sits is three different answers, not one**, and section 9 - gives each separately rather than flattening them. The database is pinned to a - **United States region**. The object storage holding backups is **not pinned to - any jurisdiction**, which means Cloudflare may hold those objects anywhere. - Every request passes through a **global** content delivery network on its way - in. Your identity data, meaning your email address, your name and your sign-in - events, is handled for us by **WorkOS in the United States**. -- You can **export** your data (`GET /v1/me/export`) and **delete** your account - (`DELETE /v1/me`) yourself, from the API or the app, without asking us. Both - are self-service; section 6 sets out what the export contains, what it - deliberately leaves out, and the conditions each route applies. - -The rest of this document is the detail behind those points. +This summary is provided for convenience. The numbered sections that follow +govern. + +- Pull.fm is free and non-commercial. We do not sell, rent or share your personal + information. There is no advertising, ad technology or affiliate revenue in the + product. +- We use no analytics, tracking pixels, advertising software, third-party + telemetry or third-party error reporting. +- We collect an identifier and email address from your sign-in provider, the + music accounts you choose to connect, the items you add to your wishlist, and + operational logs. +- Your connected-service credentials are encrypted and are never exported, logged + or displayed. +- Pull.fm is offered to residents of the United States. We refuse registration + from the European Economic Area, the United Kingdom and Switzerland. +- You may export your data and delete your account yourself, at any time, without + contacting us. --- -## 2. Who is responsible for your data +## 2. Who we are **312.dev LLC**, a limited liability company organised under the laws of -**Illinois, United States**, is responsible for your personal data. - -| | | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Responsible party | 312.dev LLC, Illinois, United States | -| Contact | `ope@312.dev` | -| Postal address | **Not published. Contact is by email.** This is a stated choice rather than an omission, and the reasoning is below. The row is kept so an address can be dropped into it later without rewriting anything. | -| Territorial scope | Pull.fm is offered to residents of the **United States**, and we refuse to create an account for anyone who appears to be in the **EEA**, the **United Kingdom** or **Switzerland**. Section 9 explains what that refusal does, what it does not do, and why the rest of this document depends on it. | - -**Why there is no postal address, and the one thing that would change it.** -Earlier versions of this document carried a placeholder here, on the grounds that -a postal address is "required for a published policy in several jurisdictions". -That was true of the GDPR posture and it is not carried forward, because keeping -a requirement that no longer exists makes a document wrong in the same way that -deleting one that does. What was actually checked: - -- **CalOPPA** (California Business and Professions Code section 22575(b)) sets - out what a posted privacy policy must contain. **It does not require a postal - address.** Checked against the statute. -- **CCPA** permits a business that operates exclusively online and has a direct - relationship with the consumer to offer an email address as the only method for - submitting requests. Pull.fm is below its threshold in any case (section 10). -- **Illinois PIPA** does not require one. **BIPA's** published-retention-policy - duty attaches only to an entity in possession of biometric identifiers, and - Pull.fm has none. -- **App store rules** would require one, and they do not apply: Pull.fm is - distributed as signed GitHub Release assets rather than through any store. - **This is a load-bearing assumption. The day Pull.fm enters an app store, a - postal address becomes mandatory** and this row has to be filled. -- **CAN-SPAM** (15 U.S.C. section 7704(a)(5)(A)(iii)) requires "a valid physical - postal address of the sender", but only in a **commercial** electronic mail - message. A magic-link sign-in code is a transactional message and is outside - that definition. **The obligation attaches on the first marketing email Pull.fm - ever sends, and not before.** Nobody would think of sending an announcement to - their users as a legal event, which is exactly why it is written down here. - Checked against the statute. - -So the address is withheld deliberately, and the two things that would reverse -that decision are named above rather than left to be rediscovered. Separately, -312.dev LLC's registered agent address is already public record at the Illinois -Secretary of State, so publishing it would cost nothing in privacy terms; whether -to do so anyway is the operator's call and has not been made. - -**Why there is no Data Protection Officer and no Article 27 representative.** -Both are GDPR offices, and neither is required of a controller the GDPR does not -reach. Section 9 sets out the basis for saying it does not reach Pull.fm, which -is Article 3(2) rather than anything about where the servers are, and states the -one condition that basis depends on. Earlier drafts of this document recorded the -absence of an Article 27 representative as a gap; it is not a gap under this -posture, and the thing that is actually missing, the registration refusal itself, -is recorded above instead. - -**We are a solo operation.** One person operates Pull.fm. That is disclosed -here because it affects how fast we can respond to you (see section 10) and -because it is a security fact you are entitled to weigh. +Illinois, United States, is responsible for the personal information described in +this policy. + +| | | +| ----------------- | -------------------------------------------------------------------------------------------------------------------- | +| Responsible party | 312.dev LLC, Illinois, United States | +| Contact | `ope@312.dev` | +| Postal address | Not published. Contact is by email. | +| Territorial scope | Offered to residents of the United States. Registration from the EEA, the United Kingdom and Switzerland is refused. | + +Pull.fm is operated by one person. We disclose this because it affects our +response times under section 10 and because it is a security characteristic you +are entitled to weigh. + +We have not appointed a Data Protection Officer or an Article 27 representative. +Both are offices of the General Data Protection Regulation, which does not apply +to Pull.fm for the reasons given in section 9. + +**Postal address.** We do not publish a postal address, and no law applicable to +Pull.fm requires one. CalOPPA (California Business and Professions Code section +22575(b)) does not require a postal address. The CCPA permits a business +operating exclusively online, with a direct relationship with the consumer, to +offer an email address as the sole method for submitting requests. Illinois PIPA +does not require one, and the published-retention-policy duty under BIPA attaches +only to an entity in possession of biometric identifiers, which we are not. Two +future events would create the obligation: distribution through an app store, +which would make a postal address mandatory under store rules, and the sending of +a first commercial email, which would engage 15 U.S.C. section +7704(a)(5)(A)(iii). A sign-in code is a transactional message and is outside that +provision. --- -## 3. What we collect, and where it comes from - -This is a table of actual database columns and log fields, not categories. - -### 3.1 Account identity (from your sign-in provider) - -Authentication is handled by **WorkOS AuthKit**, and **WorkOS processes this -data in the United States**. See section 9 for the transfer mechanism and what -WorkOS is and is not permitted to do with it. - -You sign in with an **emailed one-time code** (WorkOS Magic Auth). Social -sign-in with Google or Apple, passkeys, and passwords are **not enabled**. This -is the whole sign-in surface: there is no other way to sign in, so this is the -only account identity we ever receive. The reasoning is in -[`../docs/PLAN.md`](../docs/PLAN.md) section 4a, and the configuration steps are -in [`../docs/runbooks/workos-setup.md`](../docs/runbooks/workos-setup.md). - -The earlier open question on this paragraph is resolved: `docs/PLAN.md` section 4 -recorded a "social plus magic link" plan, and section 4a now supersedes it with -magic link only. The decision is enforced rather than documented, by a test that -fails if any password, social, passkey or SSO route is ever registered and by a -database constraint on `users.auth_method`, so this paragraph cannot quietly -stop matching the deployed configuration. If social sign-in were ever turned on, -this section would have to change, because the provider would then also tell us -which third-party account you used. - -| What | Where it is stored | Source | -| -------------------------------------------------------------------------- | -------------------- | ------ | -| A stable identifier from WorkOS (`workos_user_id`) | `users` table | WorkOS | -| Your email address (lower-cased) | `users.email` | WorkOS | -| A display name, formed from the first and last name your provider supplied | `users.display_name` | WorkOS | -| Account created and updated timestamps | `users` | us | - -**We never receive, store, or create a password.** There is no password column, -no password hash, and no password reset flow, deliberately: Pull.fm issues no -passwords at all. - -WorkOS is a **service provider** acting on our instructions, under the WorkOS -Data Processing Addendum published at -[workos.com/legal/dpa](https://workos.com/legal/dpa). That addendum is -incorporated into the WorkOS agreement automatically and needs no separate -signature. - -Two things in that addendum are worth stating plainly rather than leaving in a -document nobody reads: - -- WorkOS may process your data "for its internal uses to build or improve the - quality of its services", to detect security incidents, and to protect against - fraud. That is a **broader permission than "only on our instructions"**, and it - is theirs, not ours. **The addendum does not say anything either way about - training AI or machine-learning models on it.** We have not obtained a separate - commitment on that point, so we do not claim one. -- Under the CCPA, WorkOS commits **not to "sell" or "share"** your personal data, - not to retain or use it for any purpose other than providing the service, and - not to combine it with data it gets from anyone else. +## 3. Information we collect + +### 3.1 Account information + +Authentication is provided by WorkOS, which processes this information in the +United States. You sign in with a one-time code sent to your email address. +Social sign-in, passkeys and passwords are not enabled, and this is enforced in +our systems rather than left to convention. + +| Information | Source | +| -------------------------------------------------------------------------- | ----------- | +| A stable account identifier | WorkOS | +| Your email address | WorkOS | +| A display name, formed from the first and last name your provider supplied | WorkOS | +| The dates your account was created and last updated | 312.dev LLC | + +We never receive, store or create a password. Pull.fm has no password field and +no password reset process. + +WorkOS acts as a service provider on our instructions under the WorkOS Data +Processing Addendum published at +[workos.com/legal/dpa](https://workos.com/legal/dpa). Two terms of that addendum +are disclosed here: + +- WorkOS may process your information for its internal use to build or improve + the quality of its services, to detect security incidents and to protect + against fraud. That permission is broader than processing solely on our + instructions. The addendum is silent as to the training of artificial + intelligence or machine-learning models, and we have obtained no separate + commitment on that subject. +- Under the CCPA, WorkOS undertakes not to sell or share your personal + information, not to retain or use it for any purpose other than providing the + service, and not to combine it with information obtained from others. ### 3.2 Connected music services -If you connect **ListenBrainz** or **Last.fm**, we store, in `user_connections`: - -| What | Notes | -| ----------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| Which provider (`listenbrainz` or `lastfm`) | plain text | -| Your username at that provider (`provider_account_id`) | plain text; not a secret, and needed to build API calls | -| **The access credential, encrypted** (`access_token_ct`) | AES-256-GCM ciphertext. See section 4. | -| **The refresh credential, encrypted** (`refresh_token_ct`), where the provider issues one | AES-256-GCM ciphertext | -| Credential expiry, granted scopes, connection status, last verification time, last error | plain text | - -Connecting is entirely optional, and you can disconnect at any time, which -deletes our copy of the credential. - -**We use those credentials only** to read the data needed to generate -recommendations for you, on your behalf. We do not use them to write to your -account at the provider, and we do not use them for anything unrelated to -serving you. - -### 3.3 Listening-derived data - -Pull.fm's purpose is to turn what you listen to into recommendations. This is -what actually happens today, stated precisely because it is the claim a policy -most often gets wrong: - -- We read listening data from ListenBrainz and Last.fm using the credential you - supplied, at the time a request needs it. -- **We do not currently maintain a per-user store of your listening history.** - There is no listening-history table in the schema. Responses from upstream - providers are cached in a table (`upstream_cache`) that is keyed by provider - and content, has no user column, and is not linked to you. -- The recommendation and feed endpoints that would consume this data are - **currently not implemented** and return HTTP 501. - -`[OPEN]` If a future release stores per-user listening history, ranking state, -or an inferred taste profile, **this section must be rewritten before that ships, -and those rows must cascade on user deletion**, or the deletion claim in section -7 becomes false. Note also that a user-scoped `upstream_cache` key would put -user-linked rows in a table the deletion cascade does not touch. - -### 3.4 What you create in Pull.fm - -| What | Where | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| Wishlist entries: artist name, title, MusicBrainz identifiers, how it got there, status, and your free-text note | `wishlist_items` | -| Personal API tokens you create: your label for it, its scopes, its rate limit, expiry, when it was last used, and **the IP address it was last used from** | `api_tokens` | - -We store **only a SHA-256 digest** of a personal API token, never the token -itself. The last-used IP is recorded at most once per minute per token. - -### 3.5 Operational and security data - -| What | Where | Why | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Request logs: request id, method, path (**query string stripped**), response status, **your IP address**, and your user agent | application logs | Debugging and abuse investigation. The query string is stripped because it can carry a search term or a credential. | -| Web server access logs | nginx on the origin node | Same | -| An audit record of credential-affecting events, from a closed list defined in [`../apps/bff/src/lib/audit.ts`](../apps/bff/src/lib/audit.ts): sign-in code requested, verified or failed; sign-in callback; session refreshed or revoked; profile updated; connection started, created, connected or deleted; personal API token created, rotated, revoked or used after expiry; export requested or downloaded; account deleted; deletion webhook accepted or rejected; unverified directory record reaped. Each holds an internal user id (or none, for the events that have no signed-in subject), the action, the outcome, non-secret context, and **your IP address** | `audit_log` | So that a security incident produces a scoped, evidenced answer rather than a mass "rotate everything" notice | -| A record that a deletion happened: the internal id of the deleted account, when it was requested and completed, and how many rows went | `deletion_log` | To prove erasure occurred, and to re-apply it if a backup is ever restored | -| Short-lived rate-limit counters, an export cooldown counter, single-use export ticket claims, and session revocations | Redis | Abuse prevention and sign-out. Keys contain opaque identifiers; values expire between 60 seconds and the remaining life of your session. | -| An idempotency record for each mutating request: your key, a hash of the request, and the response we returned | `idempotency_keys` | So a retry on a flaky mobile connection does not create a duplicate. **Stops being valid after 24 hours, and the row is deleted an hour after that.** See section 8 | - -**IP addresses are personal data and we treat them as such.** We keep them -because a service that spends a third party's rate-limited quota cannot -investigate abuse without them; that is the legitimate interest relied on in -section 6. - -### 3.6 What we do not collect - -Stated because absence is a feature and an absent thing is invisible: - -- **No advertising or analytics of any kind.** No Google Analytics, no Segment, - no PostHog, no Mixpanel, no Amplitude, no session replay, no ad SDK, no +If you connect ListenBrainz or Last.fm, we store which service you connected, +your username at that service, the access credential and any refresh credential +we are issued, and the connection's status, permissions, expiry, last +verification time and last error. + +The credentials are encrypted. Everything else in that list is held as ordinary +text; your username at those services is not a secret and is needed to make +requests on your behalf. + +Connecting is optional. You may disconnect at any time, which deletes our copy of +the credential. + +We use these credentials solely to read the information required to generate +recommendations for you. We do not use them to write to your account at the +service, and we do not use them for any unrelated purpose. + +### 3.3 Listening information + +We read listening information from ListenBrainz and Last.fm using the credential +you supplied, at the time a request requires it. + +We do not keep a record of your listening history. Responses from those services +are held in a shared cache that is organised by service and by content, carries +no account identifier, and is not linked to you. + +Recommendations and the personalised feed are not yet available. + +### 3.4 Information you create + +- **Wishlist entries**, including the artist and title, the recording + identifiers, how the entry was added, its status and any note you write. +- **Personal API tokens**, including your label for the token, its permissions, + rate limit and expiry, when it was last used, and the IP address it was last + used from. + +We store only an irreversible fingerprint of a personal API token, never the +token itself, so we cannot recover or re-display it. The last-used IP address is +recorded at most once per minute per token. + +### 3.5 Operational and security information + +- **Request logs**, holding a request identifier, the method and path with any + query string removed, the response status, your IP address and your browser or + client identification. The query string is removed because it may carry a + search term or a credential. +- **Web server access logs**, holding the same. +- **Security records** of events that affect credentials, drawn from a fixed + list: sign-in code requested, verified or failed; sign-in completed; session + refreshed or revoked; profile updated; a music service connection started, + created, connected or deleted; a personal API token created, rotated, revoked + or used after expiry; an export requested or downloaded; an account deleted; a + deletion notice from your sign-in provider accepted or rejected; an unverified + sign-in record removed. Each record holds an internal account identifier where + one exists, what happened, the outcome, non-secret context and your IP address. +- **A record that a deletion occurred**, holding the internal identifier of the + deleted account, when it was requested and completed, and how much was removed. +- **Short-lived counters** for rate limiting, export cooldowns, single-use export + tickets and sign-outs. These hold opaque identifiers and expire between 60 + seconds and the remaining life of your session. +- **A record of each change you make**, holding the key you supplied, a + fingerprint of the request and the response we returned, so that a retry on an + unreliable connection does not duplicate the change. See section 8. + +IP addresses are personal information and we treat them as such. We retain them +because a service that consumes another company's rate-limited quota cannot +investigate abuse without them. + +### 3.6 Information we do not collect + +- No advertising or analytics of any kind, including Google Analytics, Segment, + PostHog, Mixpanel, Amplitude, session replay, advertising software and device fingerprinting. -- **No third-party crash or error reporting service.** Errors go to our own - logs. -- **No precise location.** We never ask for or store GPS coordinates. The live - events feature, if enabled, accepts a **city name only**, and the backend - rejects coordinate-shaped input outright, because our events provider's terms - forbid personal data reaching their API. -- **No contacts, photos, calendar, microphone, or device identifiers.** -- **No biometric identifiers and no biometric information of any kind.** No - voiceprint, no retina or iris scan, no fingerprint, no scan of hand or face - geometry, and nothing derived from any of them. This negative is stated on its - own line rather than folded into a list because 312.dev LLC is an Illinois - company and the **Illinois Biometric Information Privacy Act** (740 ILCS 14) - applies to a private entity regardless of its size or revenue and is enforced - by private plaintiffs. It is also checkable rather than asserted: sign-in is an - emailed one-time code, **passkeys and WebAuthn are not enabled** and a database - constraint on `users.auth_method` plus a test refuse to let them be, and no - column in any migration holds anything of this kind. -- **No health, political-opinion, religious or sexual-orientation data** is asked - for. Musical taste can be revealing, which is why we treat it as sensitive in - practice even though no United States statute classifies it that way. -- **No passwords.** -- **No payment data.** Pull.fm is free and takes no payments. - -**A design constraint rather than a paperwork step.** If Pull.fm ever adds a -feature that uses voice, face or anything else BIPA covers, the Act's written -notice, written release and published retention schedule attach **before the -first collection**, not afterwards. There is no version of that feature that can -be shipped first and papered later, so it is recorded here as a constraint on -what may be built. +- No third-party crash or error reporting. +- No precise location. We do not request or store GPS coordinates. The live + events feature, where enabled, accepts a city name only, and the service + rejects anything shaped like a coordinate. +- No contacts, photographs, calendar, microphone or device identifiers. +- **No biometric identifiers and no biometric information of any kind**, + including voiceprints, retina or iris scans, fingerprints, scans of hand or + face geometry, and anything derived from them. Sign-in is a one-time code sent + to your email address; passkeys and other biometric sign-in methods are not + enabled; and nothing of this kind is held anywhere in our systems. The Illinois + Biometric Information Privacy Act (740 ILCS 14) applies to a private entity + regardless of size or revenue, and if Pull.fm ever introduces a feature within + its scope, the Act's written notice, written release and published retention + schedule will be in place before any collection occurs. +- No health, political opinion, religious or sexual orientation information is + requested. We treat musical taste as sensitive in practice, although no United + States statute classifies it as such. +- No passwords. +- No payment information. Pull.fm is free and accepts no payments. --- -## 4. How connected-service credentials are protected - -This is the highest-value data in the system, and it is not ours: a Last.fm -session key is a credential on **your** Last.fm account, and Last.fm session keys -do not expire. So it gets its own section. - -- Each credential is encrypted with **AES-256-GCM** under a **per-record data - key**, and that data key is itself encrypted by an application-wide key that - never enters the database. This is "envelope encryption": the database holds - ciphertext and a wrapped key, and neither is usable without the application - key. -- The encryption is **bound to your account, the provider, and the specific - column**. Moving one user's ciphertext onto another user's row does not - decrypt; it fails authentication. -- Credentials are **never written to logs, traces, error messages, or support - transcripts.** The logger redacts a fixed list of field names, and a static - analysis rule fails the build if a credential-shaped value reaches a logger. -- Credentials are **excluded from your data export** on purpose. Section 5 - explains why, since that is a deliberate restriction of a right you have. -- The application key is held outside the database and escrowed. `[OPEN]` The - escrow currently has a **single holder** (the operator), which is recorded as - an accepted risk (`PULLFM-RISK-003`). That register was public until - 2026-07-29 and is now held privately, for the reasons in - [`../security/README.md`](../security/README.md); the gap it records is - unchanged, and is stated here rather than only there. +## 4. How we use information ---- +| Purpose | Information used | +| ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| Creating and operating your account | Your account information | +| Connecting a music service and generating recommendations | The connection credential and your username at that service | +| Maintaining your wishlist | Your wishlist entries | +| Operating personal API tokens | Token details and the last-used IP address | +| Security, abuse investigation, and protecting another company's rate-limited quota | Logs, IP address, client identification, security records | +| Evidencing a deletion | The deletion record and the erasure ledger | +| Complying with law | As required, including the duties in section 13 | -## 5. Your data export, and what it deliberately leaves out +We do not process personal information for marketing, advertising, profiling for +advertising, or automated decision-making producing legal or similarly +significant effects. Recommendation ranking is automated and determines only +which music is displayed to you. -**No United States law currently requires Pull.fm to offer this** (section 10 -explains why). It is offered anyway, as a matter of policy, because a service you -cannot get your own data out of is a service you cannot leave. It was built -before it was compelled and it is not being withdrawn now that it is not. - -`GET /v1/me/export` produces a JSON document containing: +--- -- **Account**: internal id, email, display name, created and updated timestamps. -- **Connections**: provider, your username there, status, scopes, and the - created / last-verified / expiry timestamps. -- **Wishlist**: every entry, in full, including your notes. -- **API tokens**: id, label, prefix, last four characters, scopes, and the - created / expiry / last-used / revoked timestamps. +## 5. Connected-service credentials -It **deliberately excludes**: +- Each credential is encrypted using AES-256, under a key unique to that record. +- That key is in turn encrypted under a master key that is never held in the same + place as the data, so the stored information is unusable on its own. +- Encryption is bound to your account, the service and the specific field. + Encrypted information moved between records will not decrypt. +- Credentials are never written to logs, traces, error messages or support + records. Automated checks fail our build if a credential can reach a log. +- Credentials are excluded from your data export. See section 6. +- The master key is held separately and escrowed. Custody currently rests with a + single holder. -- third-party access tokens and refresh tokens; -- Last.fm session keys; -- personal API token secrets, and even their digests; -- envelope encryption material (wrapped data keys, key identifiers). +--- -**Why, in plain terms.** What an export is for is giving you back the personal -data **you provided**, in a form a machine can read. A ListenBrainz -token is not information about you; it is a bearer credential for someone else's -system, which you can regenerate at that system in under a minute. If we put it -in the export, then a single stolen copy of that file would become a permanent -takeover of your Last.fm and ListenBrainz accounts, invisible to us, on systems -we do not control and cannot revoke. You lose nothing you cannot recover at the -source; the alternative risks something you cannot undo. +## 6. Access and export -The export document says this in its own `notice` field, so the exclusion is -visible to the person receiving the file and not only to whoever reads this -policy. +You may request a copy of everything we hold about you. The export contains your +account record, your connected services and their status, your wishlist in full +including any notes, and the details of any personal API tokens you have created. -**How it is delivered.** The export request returns a **single-use download link -valid for about 10 minutes**, rather than the document itself, and you can -request one at most once every 5 minutes. The download is served with -`Cache-Control: no-store`. A personal API token **cannot** request or download an -export; that requires an interactive session. +The export **excludes** your third-party access and refresh credentials, Last.fm +session keys, personal API token secrets, and the encryption material protecting +them. A credential for another service is not information about you; it is a key +to someone else's system, which you can replace at source in a minute. Including +it would mean that a single disclosure of your export file resulted in a lasting +compromise of your ListenBrainz and Last.fm accounts, on systems we neither +control nor can revoke. The export file states this exclusion on its face. ---- +An export request produces a single-use download link valid for approximately ten +minutes. You may request one export every five minutes. An export cannot be +requested or downloaded with a personal API token; you must be signed in. -## 6. Why we hold each thing - -**This is not a lawful-basis analysis, and it deliberately no longer is one.** -Earlier versions of this document mapped every purpose onto a GDPR Article 6 -basis. United States privacy law does not work that way: it does not ask a -controller to select a basis before processing, so a table of Article 6 citations -in a document for United States residents would be borrowed vocabulary rather -than a statement about anything. The table is kept without the citations, because -what it actually says, which data is held for which purpose, is true either way -and is the part you can hold us to. - -| Purpose | Data | Why | -| ---------------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Create and operate your account | identity from WorkOS | There is no account without it | -| Connect a music service and generate recommendations | connection credential, your provider username | Only on your explicit instruction to connect, and only for that | -| Keep your wishlist | wishlist entries | It is the feature | -| Personal API tokens | token metadata, last-used IP | So a token you did not expect to be in use can be recognised as such | -| Security, abuse investigation, protecting a third party's rate-limited quota | logs, IP, user agent, audit records | A service that spends someone else's rate-limited quota cannot investigate abuse without them. Limited to what identifies a request source; never used for profiling or marketing | -| Proving an erasure happened | `deletion_log`, erasure ledger | So that an erasure can be demonstrated, and re-applied if a backup is restored | -| Complying with law | as required | Including the breach-notification duties in section 12 | - -**There is no processing for marketing, advertising, profiling for advertising, -or automated decision-making with legal or similarly significant effects.** -Recommendation ranking is automated, but it decides what music to show you and -nothing else. +No United States law currently requires us to offer export. We offer it as a +matter of policy and it will not be withdrawn. --- ## 7. Deleting your account -`DELETE /v1/me` (or "delete account" in the app) is irreversible, and it does the -following, in this order: - -1. A **deletion record is written first**, so that a failure part-way through - leaves a durable, retryable record that you asked to be erased. -2. A **single database transaction** deletes your `users` row. Every table that - holds your data declares `ON DELETE CASCADE`, so your connections, wishlist, - API tokens, idempotency records, and in-flight connect state go with it, - atomically, rather than in an application sweep that can half-fail. This is - asserted against a real database on every CI run. -3. Your identity is **deleted at WorkOS**. If that call fails, your local data is - still gone and the failure is recorded so it can be retried. -4. Redis keys scoped to you are removed from both instances. - -Because deletion is irreversible, the route requires: an **interactive session** -(a read-only personal API token is refused), a **recent sign-in** (within 15 -minutes by default), and **your account email typed back** in the request. - -If your identity is deleted at WorkOS instead, a signed webhook triggers the same -cascade here, so an account deleted upstream does not leave orphaned data. - -### What survives deletion, honestly - -| Survives | Contains | Why | -| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Deletion record** (`deletion_log`) | the internal id of the deleted account, timestamps, row counts | To demonstrate erasure, and to re-apply it if a backup is restored | -| **Audit records** (`audit_log`) | for the first 30 days after deletion: the internal id of the deleted account, the action, the outcome, and **the IP address** of the request. After that, the same rows with the id replaced by an irreversible random pseudonym and the IP truncated to its network prefix | Security evidence must survive the deletion it records, or a hostile deletion erases its own trail. Deleting an account is a plausible last step of an account takeover | -| **Encrypted backups** | your rows, as they were at backup time | See below | -| **Logs** | request id, internal id, IP, user agent - no email, no credential | See section 8 | - -**`audit_log` has a retention limit, and it is enforced by code rather than by -intention.** Audit rows are kept, because a trail a user can erase by deleting -their account is worthless in exactly the case it exists for, but the -identifiers in them are removed after a bounded window: - -- The internal account id is replaced by a **random pseudonym**. It is a fresh - random UUID minted inside the database statement that applies it, once per - account per batch, and written **nowhere else**. There is no key, no pepper, - and no mapping table, so nobody, including us, can reverse it. A keyed hash - was considered and deliberately rejected: because `deletion_log` permanently - retains the id of every deleted account, a keyed scheme would be reversible by - anyone who held both the key and that table. A random value has no such - property, which is why it was chosen. -- The IP address is **truncated to its network prefix**: a `/24` for IPv4, a - `/48` for IPv6. The host part is overwritten in place and is not recorded - anywhere else. -- Rows that never had a signed-in subject (a rejected webhook, a reaped - directory record, a failed sign-in attempt) carry no id to replace, so they - get **no pseudonym**; their IP is truncated on the same schedule. - -The windows: **90 days** at full fidelity, or **30 days** after the account is -deleted, whichever comes first, then anonymized in place; **400 days** from the -event, then hard deleted. The reasoning behind each number, and the -legitimate-interest assessment for keeping the rows at all, is in -[`../docs/compliance/data-retention-policy.md`](../docs/compliance/data-retention-policy.md). -The mechanism is -[`../apps/bff/src/services/audit-retention.ts`](../apps/bff/src/services/audit-retention.ts), -run by `pnpm --filter @pull-fm/bff purge:audit`, and it is covered by -integration tests against a real database that assert both what it must -anonymize and what it must refuse to. - -`[OPEN]` **The schedule exists but has never fired, because no compute is -deployed to run it.** See the note at the end of section 8, which applies to -every window stated in this section. Until a run has actually happened, these -are the windows the system applies **when the job is run**, not periods after -which data has automatically gone, and this policy must not be published -stating them without that qualification. - -### Backups - -Encrypted backups are retained for a point-in-time-recovery window, and **we do -not selectively rewrite them.** Rewriting a backup destroys the integrity that -makes it a backup, and the attempt would be a larger risk to every other user's -data than the residual retention is to yours. This is the position regulators -accept, and it does not change with the jurisdiction: it is an argument about -what a backup is. It comes with three commitments that make it meaningful: - -1. Backups are **put beyond use**: encrypted at rest, access-controlled with a - credential that is not the database credential, and never queried to serve - live traffic. -2. Retention of the automatic history is **bounded**: your data disappears from - it when the last point in time containing it ages out. -3. **A restore replays the deletions, from a record kept outside the database.** - Before a restored system serves traffic, every account in that record is - re-deleted. This wording was corrected after a restore drill on 2026-07-29 - falsified the previous version, and the correction is worth stating plainly: - the replay list used to be `deletion_log`, a table **inside** the database - being restored, so rolling back past an erasure rolled back the erasure and - the evidence of it at the same instant. That claim was not merely unverified, - it was not satisfiable. The list now lives in object storage, one immutable - object per erasure, and a drill confirmed it removes a resurrected account, - rebuilds its `deletion_log` row, and leaves untouched the accounts that never - asked to be deleted. - - **The residual gap this section used to record is closed.** The object used to - be written by a job that ran every ten minutes, so an erasure completed inside - that window and followed immediately by a restore could have been lost. It is - now written **inline with the deletion, before anything is destroyed**: if - that write fails the request returns 503 and deletes nothing, so you can tell - "we did not delete you, retry" from "we deleted you and something else went - wrong". Your erasure is durable at the moment you ask for it rather than at - the next run of a job. The ten-minute job still runs, as a reconciler that - backfills records predating the inline write and is the only thing that would - notice the two records diverging. - -4. A restored backup yields your connected-service credentials **only as - ciphertext**, under a key that was never in the database. - -**The point-in-time-recovery window is 7 days.** The database is hosted by Neon, -which keeps a copy-on-write history rather than periodic snapshots, and the -window is set in this repository as `history_retention_seconds = 604800` in -[`../infra/neon/variables.tf`](../infra/neon/variables.tf). In practice that -means a deletion stays within point-in-time recovery for seven days after it is -applied, and is beyond it after that. - -**This figure was 6 hours until 2026-07-29 and the change is worth stating rather -than silently substituting.** Six hours was the ceiling on the free plan the -retired European project was on. The United States project is on a paid plan and -was created with a seven-day window, so the number moved because the plan and the -project moved, not because somebody tuned it. It is stated here in the direction -that matters to you: **a longer recovery window means your deleted data remains -restorable for longer**, which is a cost of durability rather than a benefit of -it, and section 6 explains how deletion and backups interact. - -**There is a second backup path, and it now has numbers.** Alongside Neon's own -history, encrypted logical dumps of the database are written to object storage, -now on a daily schedule rather than by hand, and a bucket lifecycle rule expires -them after **35 days**. Daily follows from the retention figure rather than being -chosen next to it: what you can restore to is the window divided by the interval, -so daily gives about 35 recovery points inside 35 days where weekly would give -five. A dump taken by hand immediately before a deliberately destructive -operation is kept longer, **90 days**, because a dump taken to survive a -destructive operation has to outlive it. - -`[OPEN]` **We can no longer read that lifecycle rule, so we can no longer verify -it.** The backup credential was deliberately narrowed to a bucket-scoped object -token, which fixed a separate finding where an account-wide grant turned out to -read every bucket in the estate. Reading a lifecycle configuration is a -bucket-admin operation, so that token is now refused it. Both figures are -therefore **configured but unconfirmed by us**, and this document must not state -them as -verified fact until the conformance check has been run from an operator -credential. [`../docs/api/deletion-and-backups.md`](../docs/api/deletion-and-backups.md) -records the same qualification, and the two must not drift apart. +Deleting your account is irreversible. It proceeds as follows: + +1. We record that you asked to be deleted, before anything is removed, so that a + failure part-way through leaves a durable record we can act on. +2. Your account and everything linked to it are removed together, in a single + step that either completes or does not happen at all, rather than a sweep that + can partly fail. This covers your connected services, wishlist, API tokens and + any in-progress connection. It is verified automatically on every change we + make to the system. +3. Your identity is deleted at WorkOS. If that fails, your information with us is + nonetheless deleted and the failure is recorded so it can be retried. +4. Short-lived records held about you are cleared. + +Because deletion is irreversible, it requires that you be signed in (a read-only +personal API token is refused), that you have signed in within the previous +fifteen minutes, and that you type your account email address to confirm. + +If you delete your identity at WorkOS instead, we are notified and the same +process runs here. + +### 7.1 What survives deletion + +| Survives | Contents | Reason | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| The deletion record | The internal identifier of the deleted account, the dates, and how much was removed | To evidence the deletion, and to re-apply it if a backup is ever restored | +| Security records | For thirty days after deletion: the internal identifier, what happened, the outcome and the IP address. Thereafter the same records with the identifier replaced by an untraceable substitute and the IP address reduced to its general network area. | Security evidence must survive the deletion it records. Deleting an account is a plausible final step of an account takeover. | +| Encrypted backups | Your information as it stood at the time of backup | See section 7.2 | +| Logs | A request identifier, an internal identifier, an IP address and client identification. No email address and no credential. | See section 8 | + +The identifiers in security records are removed on a fixed schedule. The internal +account identifier is replaced by a randomly generated substitute, created at the +moment of replacement and recorded nowhere else. There is no key and no lookup +table, so the substitution cannot be reversed by anyone, including us. IP +addresses are reduced to their general network area, with the part identifying a +specific connection overwritten. Records that were never associated with a +signed-in person have no identifier to replace and receive no substitute; their +IP addresses are reduced on the same schedule. + +The periods are ninety days in full, or thirty days following account deletion, +whichever comes first, after which records are made anonymous; and four hundred +days from the event, after which records are deleted outright. + +### 7.2 Backups + +Encrypted backups are retained so that the service can be recovered. We do not +selectively edit backups to remove individual accounts. Editing a backup destroys +the integrity that makes it a backup, and doing so would present a greater risk to +every other user's information than the residual retention presents to yours. The +following commitments apply instead: + +1. Backups are put beyond ordinary use. They are encrypted, reachable only with a + credential separate from the one the service uses, and never read to answer a + live request. +2. Retention is limited. Your information leaves the backup set when the last + backup containing it expires. +3. **A restore repeats the deletions.** We keep a list of deleted accounts + outside the database, and before a restored system serves anyone, every + account on that list is deleted again. The list is kept outside the database + because a list kept inside it would be reverted by the same restore it exists + to correct. Each entry is written at the moment you ask to be deleted, before + anything is removed; if that write fails, your request fails and nothing is + deleted, so you are never told you were deleted when you were not. +4. A restored backup yields your connected-service credentials only in encrypted + form, under a key that was never stored with them. + +Backups allow recovery to any point within the previous **seven days**. A +deletion therefore remains recoverable for seven days after it is applied. A +longer recovery window means deleted information stays restorable for longer. + +We additionally take a daily encrypted copy of the database, retained for +**thirty-five days**, and a copy taken by hand before any deliberately +destructive maintenance, retained for **ninety days**. Our access to that storage +is deliberately limited to the files themselves, which means we cannot read back +the retention setting; these two periods are therefore stated as configured +rather than as independently verified by us. --- -## 8. How long we keep things - -| Data | Retention | -| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Account, connections, wishlist, API tokens | Until you delete them or delete your account | -| Idempotency records | They stop being **valid** after 24 hours, which the schema enforces, and the row itself, including the cached response body, is **deleted an hour after that**. The extra hour is slack against clock skew, so the sweep can never remove a record an in-flight request still considers valid. Enforced by `sweep:expired`, whose intended cadence is hourly. **See the scheduling note below.** | -| In-flight connect state | Same shape and the same job: `expires_at` is minutes after issue, and the row is deleted an hour past it. **See the scheduling note below.** | -| Redis rate-limit counters, export cooldowns, export tickets | 60 seconds to about 11 minutes | -| Session revocations in Redis | Until the revoked session would have expired anyway | -| Application and web server logs | `[OPEN]` **No retention period is configured anywhere in the system today.** Logs are intended to ship to a hosted log service, and no numeric retention exists in infrastructure code. A number must be set and stated here before publication; a policy that says "we keep logs for N days" while nothing enforces N is a false statement. | -| Audit records | **90 days** at full fidelity, or **30 days** after your account is deleted, whichever comes first. Past that the row is anonymized in place: the account id is replaced by an irreversible random pseudonym and the IP is truncated to a `/24` or `/48`. The anonymized row is hard deleted **400 days** from the event. Applied by `purge:audit`, whose intended cadence is daily. See section 7 and **the scheduling note below**. | -| Personal API token last-used IP | Cleared **90 days** after the token was last used, by setting the column to null in place. A token with an IP recorded but no recorded use is also cleared, because that state can only come from a bug and the safe resolution is to drop the IP. Applied by the same `purge:audit` job. **See the scheduling note below.** | -| Deletion records | Indefinite. They hold an internal identifier and timestamps, and nothing else. | -| Encrypted backups | **7 days** for Neon's point-in-time-recovery history, read back from the live project on 2026-07-29. **35 days** for the daily encrypted logical dumps in object storage, and **90 days** for a dump taken by hand before a destructive operation, both enforced by a bucket lifecycle rule. `[OPEN]` We hold a bucket-scoped credential that is refused lifecycle reads, so the 35 and 90 day figures are configured but unverifiable by us today. See section 7. | -| Identity data held by WorkOS | Deleted when you delete your account. Separately, an address that was sent a sign-in code but never verified leaves an **unverified record at WorkOS and no account here**; `reap:unverified` deletes those once they pass `AUTH_UNVERIFIED_REAP_AFTER_S`, and **the scheduling note below applies to that job too**. On termination of our agreement with WorkOS, their addendum commits them to delete identity data other than backup and archival copies, which go on their own schedule. `[OPEN]` That schedule is not published. | - -### `[OPEN]` The scheduling note, which qualifies five rows above - -**The retention jobs are written, tested and scheduled, and no schedule has ever -fired, because no compute is deployed to run it.** This has to be stated rather -than glossed, because the difference between "a job exists" and "a job has run" -is the whole difference between a retention commitment and a retention -aspiration. Until the schedule has somewhere to run, the windows in the table -above are the windows the system applies **on each run**, not periods after -which data has automatically gone. The three stages are separated below so it -is clear which one is outstanding. - -What exists: `purge:audit` (the audit anonymization, the 400-day delete, and the -token last-used-IP clearing), `sweep:expired` (the idempotency and connect-state -deletion), and `reap:unverified` (the unverified WorkOS records). All three are -real code with the windows above compiled in, all three are covered by -integration tests that run against a real database, all three are safe to run -twice and safe to interrupt, and all three refuse to run concurrently with -themselves. Run any one of them and it enforces exactly the numbers in this -table. - -What now exists: **a schedule for each of them.** Every job has a systemd timer -committed to this repository, enabled by the application node's bootstrap, with -a run deadline shorter than its own interval so two runs cannot overlap. A CI -check asserts the units exist, are enabled rather than merely installed, and -expand to the intervals documented here. - -What still does not exist: **anywhere for those timers to run.** No compute is -deployed, so no timer has ever fired. The schedule is real and dormant, not -running. - -So the honest statement of the current position is: **these are the retention -windows the system applies each time the job is run, the schedule that will run -them is written and verified, and no run has yet happened because nothing is -deployed.** Until a deployment exists and a run is observed, this policy must not -state the windows above as unqualified promises, and the `[OPEN]` marker stays. -Closing it now requires evidence that a timer fired, not more configuration. - -The full schedule, the reasoning behind each number, and the legitimate-interest -assessment for the security audit trail are in -[`../docs/compliance/data-retention-policy.md`](../docs/compliance/data-retention-policy.md). +## 8. Retention + +| Information | Retention | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Account, connected services, wishlist, API tokens | Until you delete them or delete your account | +| Records of changes you make | Ineffective after 24 hours, and deleted one hour later | +| In-progress service connections | Expire minutes after starting, and are deleted one hour later | +| Rate-limit counters, export cooldowns, export tickets | 60 seconds to approximately 11 minutes | +| Sign-out records | Until the session would otherwise have expired | +| Application and web server logs | No retention period is currently configured. A period will be set and stated here before publication. | +| Security records | 90 days in full, or 30 days following account deletion, whichever comes first, then made anonymous; deleted 400 days from the event. See section 7.1. | +| Personal API token last-used IP address | Cleared 90 days after the token was last used | +| Deletion records | Indefinite. They contain an internal identifier and dates only. | +| Encrypted backups | 7 days for point-in-time recovery. 35 days for the daily copy, and 90 days for a copy taken before destructive maintenance, both as configured. See section 7.2. | +| Information held by WorkOS | Deleted when you delete your account. An email address that was sent a sign-in code but never used leaves a record at WorkOS and no account with us; those are deleted automatically on expiry. On termination of our agreement, WorkOS undertakes to delete your information other than backup and archival copies, the schedule for which WorkOS does not publish. | + +**The periods in this section are applied each time the corresponding process +runs.** Each is built, tested and scheduled, and the schedule is verified +automatically. No scheduled run has yet taken place, because the service is not +yet deployed. Until then, these are the periods the system applies when it runs, +rather than periods after which information has automatically gone. --- -## 9. Where your data is, who else touches it, and which country's law applies - -### Why this document is no longer a GDPR document - -Earlier versions of this policy were written for the General Data Protection -Regulation. They are not, and the sections that carried the Article 27 -representative, the Chapter V transfer safeguards and the Standard Contractual -Clauses have been removed rather than softened. The basis for removing them is -below, so that it can be argued with rather than taken on trust. - -**The GDPR reaches a company outside the Union through Article 3(2), which turns -on whether the company offers goods or services to people who are in the Union. -It does not turn on where the servers are.** That is the load-bearing point, and -it cuts both ways: moving the infrastructure to the United States does not by -itself put Pull.fm outside the GDPR, and leaving it in Frankfurt would not by -itself put Pull.fm inside it. What decides it is who the service is offered to. - -Pull.fm's position is therefore built on the offering rather than the hosting: - -- The service is **offered to residents of the United States**, and says so here - and in the terms of service. -- **Registration from the EEA, the United Kingdom and Switzerland is refused**, - in the running system and not only on this page. The next subsection sets out - exactly what that control does. -- There is no targeting of any of those places. No European language, no - European currency, no European marketing, no European top-level domain, and no - payment of any kind since the service is free. - -### What the registration refusal actually does - -This is described in detail rather than asserted, because the whole territorial -analysis above rests on it and because it is easy to claim more for it than it -delivers. - -**What it does.** Before Pull.fm contacts its identity provider, it checks the -country our content delivery network reports for the address the request came -from. If that country is on the restricted list, the request is refused -immediately with a plain explanation. **No account is created and no sign-in -email is sent**, and because the check runs first, **no record of the address is -created at the identity provider either.** That last point is structural rather -than a promise, and there is a test asserting the provider's directory does not -contain a refused address, alongside a control test proving an allowed address -does reach it. - -**What the list covers.** The 30 EEA states, meaning the 27 EU member states plus -Iceland, Liechtenstein and Norway; the United Kingdom; Switzerland; **European -Union territory that geolocation reports under its own country code** rather than -its member state's, which is why Reunion, Mayotte, French Guiana, Guadeloupe, -Martinique, Saint Martin and the Aland Islands are named individually, since -without them the list would have holes; and the code our provider sends for a -European address it cannot resolve to a country. - -**What it deliberately does not cover**, each after consideration: overseas -countries and territories associated with the EU rather than part of it, such as -Greenland, the Faroe Islands, Saint Barthelemy, Saint Pierre and Miquelon, New -Caledonia, Aruba, Curacao and Sint Maarten, none of which are within EU -territorial scope; and Gibraltar and the Crown Dependencies of Jersey, Guernsey -and the Isle of Man, which are not the United Kingdom and have their own data -protection regimes. - -**What it is not, stated plainly.** - -- **It refuses registration, not access.** Existing accounts keep working, no - account was deleted, sessions still refresh, sign-out is never refused, and the - rest of the API answers normally. This is not a traffic block. -- **It is IP geolocation, so it is not proof.** A virtual private network or a - mis-located address defeats it in either direction. **This document does not - claim that no resident of those regions can hold a Pull.fm account.** It claims - that we do not knowingly open one, that we take reasonable measures not to, and - that our intent is stated rather than inferred. That is what an address-based - control can support, and claiming more would be the same kind of overstatement - this document exists to avoid. -- **An undeterminable origin is refused, including Tor.** Otherwise the list - would be optional. - -**The three places are three separate legal regimes and the refusal has to name -all three**, which is why this document does not say "Europe": - -| Territory | What it is | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **European Economic Area** (30 states) | The 27 EU member states plus **Iceland, Liechtenstein and Norway**, which are not in the EU but into which the GDPR is incorporated by the EEA Agreement | -| **United Kingdom** | Outside the EU since Brexit, with its **own UK GDPR** and Data Protection Act. An EEA list that omits it misses a separate statute, not a rounding error | -| **Switzerland** | Never in the EEA. Governed by its own **Federal Act on Data Protection**, which is GDPR-shaped but is a different law with its own regulator | - -**None of this is a legal opinion and it has not been reviewed by a lawyer.** It -is the operator's reasoning, written down so a lawyer can check the reasoning and -not only the conclusion, and so a regulator reading it can see what was relied on. - -### Where each thing actually sits - -**There is no single answer, and giving one would be the misleading version.** -The database is pinned to a region. The object storage is not pinned at all. The -network in front of both is global by design. Those are three different -statements and they are given separately below. - -| Data | Where | -| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Account row, connected-service credentials, wishlist, API tokens, audit and deletion records | **Neon** Postgres, **United States region** (`aws-us-east-1`) | -| Cache and rate-limit counters | The application servers | -| Application servers | **Hetzner Cloud**, Ashburn, Virginia, United States | -| Encrypted database backups, and the erasure ledger | **Cloudflare R2**, **no jurisdiction pinned**. See the paragraph below | -| **Email address, name, sign-in events, session records** | **WorkOS, United States** | -| **Every request in transit: your IP address, the URL path, your headers** | **Cloudflare**, at whichever data centre is nearest you, anywhere in the world | -| Operator access | From the United States, by one person | - -**The object storage is unpinned, and "unpinned" is not a synonym for "in the -United States".** Cloudflare R2 offers exactly two data-residency jurisdictions, -`eu` and `fedramp`. **There is no United States jurisdiction to choose.** The -backup and erasure-ledger buckets are created with no jurisdiction, which R2 -calls `default`, and what that means is that Cloudflare may store those objects -anywhere it likes. A location hint can be given at creation, and a hint is a -preference rather than a guarantee. So the true statement about backups is that -they are **not pinned to the EU**, and this policy declines to make the stronger -claim that they are held in the United States, because nothing in the platform -would make that true. The objects are encrypted before they are uploaded, under a -key that has never been in Cloudflare, which is the control that actually does -the work here regardless of where they land. - -**The database residency is a real guarantee, not a setting.** A Neon project's -region is chosen at creation and **Neon does not permit changing the region of an -existing project**, so it is a property of the project rather than something -somebody could flip. - -**The Cloudflare edge is global on purpose and no guarantee is available.** Every -DNS record for Pull.fm is proxied through Cloudflare deliberately: an unproxied -record would publish the origin server's IP address and remove the whole edge -trust boundary. The consequence is that Cloudflare's global anycast network -answers every request and TLS is terminated wherever that lands, which means your -IP address, the URL path, your user agent and the request body are in the clear, -in memory, at a data centre that may be in any country. The only Cloudflare -product that confines TLS termination to a chosen region is an Enterprise add-on -this deployment is not on. This is a disclosed property of the architecture -rather than a setting left wrong, and it is stated here because a policy that -answered only the storage question would leave you with a false picture. - -**The placements in the table above are applied, and they were re-read from the -providers rather than from the plan.** This paragraph previously said the opposite -and it is the sentence in this document that was wrong for the longest, so what -changed is recorded rather than quietly replaced. Until 2026-07-29 the -infrastructure code pinned the database to `aws-eu-central-1` in Frankfurt, -accepted only the EU sites `fsn1`, `nbg1` and `hel1` for the application node, -with the live node in Helsinki, and defaulted the backup bucket to the `eu` -jurisdiction. On 2026-07-29 all three moved, and the European estate was -**deleted** rather than left in place: the Frankfurt database project, its object -storage, and the credentials scoped to it no longer exist. The database region was -confirmed as `aws-us-east-1` by reading the live project back from the provider's -API, not by reading the Terraform that asks for it. - -`[OPEN]` **What is applied is not the same as what is pinned by committed code, -and two of the rows above are held in place by something less durable than the -rest.** First, the application node's location is supplied from an operator file -that is deliberately not committed, while the checked-in default for that variable -is still a European site; the live node is in Ashburn, and a fresh apply that -forgot the operator file would not be. Second, the **erasure-ledger bucket is not -described by the infrastructure code at all**, so unlike the backup bucket there -is no committed artifact that fixes or checks its placement. Neither of these -makes a sentence above false today. Both mean a true sentence above is one -forgotten input away from becoming false, which is the condition this marker is -recording. Before publication, the committed default should name the site the -table names, and the erasure ledger needs a placement something enforces. - -### Cross-border processing - -**What used to be in this section, and why it is gone.** Previous versions -carried a full Chapter V analysis: Standard Contractual Clauses in two modules -for WorkOS, the UK International Data Transfer Addendum, the same clauses applied -to Swiss law, and the observation that the United States holds no adequacy -decision. All of it existed to legitimize moving personal data **out of the -EEA**, and none of it has anything left to do. - -Two independent reasons, either of which is sufficient: - -1. **After the move there is no EEA-resident data to transfer.** Chapter V - governs a transfer from the Union to a third country. Nothing in the estate - will be in the Union. -2. **A person in the EEA sending their own data directly to a controller in the - United States is not a "transfer" out of the EEA in the first place.** This is - the European Data Protection Board's own reading of what a restricted transfer - is: it requires an exporter subject to the GDPR sending to an importer, and a - data subject typing their own email address into a United States service is - not that. So even in the period before registration is refused, the SCCs were - answering a question that was not being asked. - -**What remains true, and is a fact rather than a safeguard**, is that data does -cross borders in the ordinary running of the service: the content delivery -network decrypts every request at whichever data centre is nearest the visitor, -and the object storage holding backups is not pinned to any jurisdiction. Both -are described above. Neither is now a compliance mechanism; both are properties -of the architecture that you are entitled to know about. - -WorkOS's own subprocessors are listed at -[workos.com/legal/subprocessors](https://workos.com/legal/subprocessors), and the -addendum gives **fourteen calendar days** to object before a new subprocessor is -engaged. You can read that page yourself, and the mechanics of it are worth -knowing because they are easy to misread as a stronger protection than they are: -**notice is given by WorkOS updating that page, not by WorkOS contacting us**, and -the addendum makes checking it our responsibility. So the fourteen days run from a -change we have to notice for ourselves. - -`[OPEN]` **An earlier version of this paragraph said "we check that list", and -nothing implemented it.** A duty with a fourteen-day clock and no inbound -notification lapses silently by design, so an intention is not a control here. The -sentence has been narrowed to what is true rather than deleted, and what would make -the stronger claim honest is a scheduled job that fetches the page, hashes it, and -alerts on a change. Until that exists, this document does not claim we monitor it. - -Under the same addendum WorkOS commits to security measures consistent with a -SOC 2 Type II programme, to notify us of a security incident "without undue -delay", to delete personal data at the end of the agreement other than backup and -archival copies, and to answer an audit once a year by completing a data -protection questionnaire. - -`[OPEN]` We have not enumerated the WorkOS subprocessor list here, because it is -served from a trust centre that cannot be read as plain text. Before publication, -read it and name any subprocessor that would surprise a reader. - -### What Cloudflare actually processes - -This is itemised rather than summarised because "we use a CDN" understates it. -Cloudflare is not merely passing bytes: it decrypts them, and **we hold its -private TLS certificate relationship precisely so that it can**, because the edge -protections we rely on need to see the request. It applies to **every visitor** -rather than only to account holders, it is continuous, and it happens in transit -rather than by storing anything. - -| What | Why Cloudflare has it | -| ----------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| **Your IP address** | It is the source address of the connection. Unavoidable for any reverse proxy | -| The **URL path**, HTTP method, status, and timing | Cloudflare has to route and cache the request | -| Your **user agent** and other request headers | Passed through, and used by the bot and abuse controls | -| The **request and response bodies**, decrypted in memory | TLS terminates at the edge, so everything in the request is in the clear at that point | -| **Nothing stored on our behalf**, other than the R2 objects | Backups and erasure-ledger entries, encrypted before upload, in unpinned buckets | - -### Service providers - -| Provider | What they handle | Where | Written security commitment | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Neon** (database) | Every application table, and the backups of it | United States region | **Data Processing Agreement, executed by both parties.** Neon signed 2024-11-22; 312.dev LLC signed 2026-07-29. Annex 2 is the security schedule | -| **Hetzner Online GmbH** | Application servers, cache | Ashburn, Virginia | **Data Processing Agreement, executed 2026-07-30**, version 1.2, with 312.dev LLC as controller. Appendix 2 is the Article 32 security schedule | -| **Cloudflare, Inc.** | DNS, and the reverse proxy every request passes through: TLS is terminated at the edge, so IP addresses, paths, headers and bodies are processed in the clear. Also object storage holding encrypted backups | **Global edge**, at the data centre nearest each visitor; object storage pinned to no jurisdiction | **Published DPA, incorporated by reference** by section 6.1 of the Self-Serve Subscription Agreement, which is the agreement this account is on. Section 6.2 is the security schedule | -| **WorkOS, Inc.** | Authentication and identity | United States | **Published Data Processing Addendum, binding without a signature** by its own sections 1(b) and 6(c). Version dated 2023-06-27. Exhibit A is the security schedule | -| **GitHub, Inc. (Microsoft)** | Source hosting and release distribution. Handles no user data. | United States | Not required | - -**Which obligation the four agreements above discharge, because "we have DPAs" is -not a checkable statement without the reason.** Earlier versions of this document -sourced the duty to **GDPR Article 28**, which required a written processor -contract. Article 28 no longer applies. But two United States laws impose a -materially similar duty on a business holding personal information about their -residents, **with no revenue and no volume threshold at all**, and Pull.fm is -offered to residents of every state: - -- **Massachusetts, 201 CMR 17.03(2)(f)**, which requires taking reasonable steps - to select service providers capable of maintaining appropriate security, and - **requiring those measures by contract**. -- **New York, the SHIELD Act** (Gen. Bus. Law section 899-bb), whose reasonable - safeguards include selecting service providers capable of maintaining - appropriate safeguards and **requiring those safeguards by contract**. - -**That duty is what the four agreements satisfy, and as of 2026-07-30 all four are -in writing.** The named schedule in each is the written security commitment the -two laws call for, which is why the table above names the schedule and not just -the agreement: Neon's Annex 2, Hetzner's Appendix 2, Cloudflare's section 6.2 and -WorkOS's Exhibit A. Each is filed as a dated document in the operator's password -vault under `pull-fm/legal/`, addressed by title. **They are deliberately not -copied into this repository**, which is public, and one of them is marked -confidential by the processor that issued it. - -Three of the four needed a question answered rather than a signature collected, -and the answers are recorded because each one is the sort of thing that gets -re-litigated later: - -- **Neon was signed by both parties even though it did not have to be.** Its - preamble binds the agreement when the last party signs, while its section 10(a) - treats signing the main agreement as entering the DPA. Those two readings - disagree about whether an unsigned block leaves you with no DPA, so it was - signed rather than resolved in our own favour. -- **Hetzner's agreement is with a German company and the server is in Virginia**, - which raised whether it covers the machine at all. It does: their terms apply to - all business relationships between the customer and Hetzner Online GmbH, their - legal notice names no United States subsidiary, and the DPA attaches to the - contract rather than to a data centre. There is no separate United States entity - to contract with. -- **Cloudflare's DPA binds us with zero European users, which is stronger than - earlier drafts of this document claimed.** Section 6.1 makes Cloudflare a - processor where customer content includes the personal data of European data - subjects **and** all data defined as personal information under the California - Consumer Privacy Act, which it then labels collectively as "Personal Data". The - two categories are the definition of that term rather than two conditions that - must both be met, so the CCPA limb stands on its own: our content falls inside - the CCPA's broad definition, so the DPA is incorporated whether or not a single - European user ever registers. Earlier notes called this conditional and - unconfirmed. It is neither, and the account was confirmed to be on the Self-Serve - Subscription Agreement from its own billing page. - -**The two statutory citations above are still the operator's reading rather than a -verified one.** They were written from memory of the regulation and the statute -rather than from the primary sources, and four separate processor records now rest -on them, which makes checking them more worthwhile rather than less. Counsel must -confirm both, and confirm whether any other state imposes the same duty, before -this is published. What is settled is that the agreements exist and are dated; what -is not settled is the citation that explains why they were needed. - -### Upstream data sources (not processors) - -**ListenBrainz/MetaBrainz, MusicBrainz, Last.fm, Apple/iTunes, Deezer, and -SeatGeek** supply data to Pull.fm. They are not processing your data on our -behalf, with one important exception: when you connect ListenBrainz or Last.fm, -**we call their API as you**, so those calls are visible to them as your -activity, under your relationship with them. - -We deliberately send them as little as possible: - -- MusicBrainz, iTunes, Deezer, and SeatGeek are queried with **content - identifiers only**, never with anything identifying you. -- The live events integration is **contractually forbidden from receiving - personal data**, which is why it takes a city name and no coordinate or postal - code, and why the code rejects coordinate-shaped input before a URL is built. -- Live event data is **not** currently served: that endpoint returns HTTP 501, - and no events provider is enabled on this deployment. - -**We do not sell or share your personal information, in any sense, including the -broad definitions of "sell" and "share" under the CCPA.** There is no advertising -partner, no data broker, no measurement partner, and no revenue model at all. +## 9. Where information is held, and applicable law + +### 9.1 The GDPR does not apply + +The General Data Protection Regulation reaches a company outside the European +Union through Article 3(2), which turns on whether that company offers goods or +services to people in the Union. It does not turn on where servers are located. +Pull.fm is offered to residents of the United States, and we refuse to create an +account for anyone who appears to be in the European Economic Area, the United +Kingdom or Switzerland. That refusal is enforced before your sign-in provider is +contacted and is covered by automated tests. Accordingly this policy contains no +Article 27 representative, no Chapter V transfer safeguards and no Standard +Contractual Clauses. + +The refusal operates on where your request appears to come from. It is not an +identity check and a determined person can circumvent it. + +### 9.2 Where information is held + +| | | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Database | A United States region | +| Application server | Ashburn, Virginia | +| Backup storage | **Not assigned to any country.** Our provider offers no United States option, so these files may be held anywhere. | +| Content delivery | Worldwide | +| Account and sign-in information | WorkOS, United States | + +No European infrastructure holds Pull.fm information. The database location was +confirmed by reading it back from the provider rather than from our own +configuration. + +Two of the placements above are held in place by settings that are not part of +our published configuration, so they depend on those settings being applied +correctly. Neither affects the placements stated today. + +### 9.3 Service providers + +We disclose personal information to the following service providers, each of whom +processes it on our instructions: + +| Provider | Function | Written security terms | +| ---------- | ---------------------------- | ------------------------------------- | +| WorkOS | Identity and authentication | Data Processing Addendum, Exhibit A | +| Neon | Database hosting | Data Processing Agreement, Annex 2 | +| Hetzner | Application hosting | Data Processing Agreement, Appendix 2 | +| Cloudflare | Storage and content delivery | Data Processing Addendum, section 6.2 | + +Each agreement is executed and on file. Two United States laws impose a duty to +require security measures of a service provider by contract, with no revenue or +volume threshold: Massachusetts 201 CMR 17.03(2)(f), which requires reasonable +steps to select and retain providers capable of maintaining appropriate security +measures and to require those measures by contract; and the New York SHIELD Act, +which imposes a materially similar duty. The schedule named in each agreement is +the written security commitment those provisions require. + +ListenBrainz, Last.fm, MusicBrainz and our live events provider are independent +sources rather than service providers acting on our instructions. We send them +only what is necessary to answer your request, and we send no personal +information to the live events provider. --- ## 10. Your rights -### What you can do right now, whether or not any law says so - -**These are offered as a matter of policy, not because a statute compels them.** -As the rest of this section explains, Pull.fm is below the threshold of every -United States privacy law that would compel either of them. They were built -before anything required them and they are not being withdrawn now that the -analysis says nothing does. - -| What | How | -| ------------------------ | --------------------------------------------------- | -| **Get all of your data** | `GET /v1/me/export`, or "export my data" in the app | -| **Delete your account** | `DELETE /v1/me`, or "delete account" in the app | - -Both are self-service and neither requires us to act. For anything else, for -example correcting a display name we somehow will not let you change, email -`ope@312.dev`. - -### Which United States laws were checked, and where Pull.fm sits - -**The honest form of this answer is "below the threshold today", not "does not -apply", because the difference matters and it is a difference the product can -cross by growing.** - -**California, the CCPA as amended by the CPRA.** Checked against the statute. -Civil Code section 1798.140(d)(1) makes a for-profit entity a covered "business" -only if it meets at least one of three tests: annual gross revenues **in excess -of $25,000,000** in the preceding calendar year, as adjusted for inflation under -section 1798.199.95; **or** it annually buys, sells or shares the personal -information of **100,000 or more** consumers or households; **or** it derives -**50 percent or more** of its annual revenues from selling or sharing personal -information. - -Pull.fm meets none of them. It has **no revenue at all**, so the first and third -tests are not close, and the third in particular cannot be met by a service with -no sale of data and no revenue to take a percentage of. The second is a volume -test and Pull.fm is pre-launch. **312.dev LLC is a limited liability company, so -the non-profit route out of the definition is not available**, and this position -rests entirely on the thresholds. It therefore changes if Pull.fm ever takes -revenue or reaches 100,000 users. - -**The other state comprehensive privacy laws.** Roughly twenty states have now -passed one. `[OPEN]` **These were not checked against their statutes and the -position on them is the operator's understanding rather than a verified one.** -That understanding is: most set either a consumer-count threshold, commonly -100,000 or a lower figure in the smaller states, or a lower count combined with a -share of revenue from selling data, and Pull.fm is below all of them while it has -no users and no revenue. **Texas and Nebraska are the ones to check first**, -because they use a Small Business Administration size test instead of a numeric -threshold, which a small operation can fail to be exempt from in a way a -threshold would not catch, and because even an exempt small business is -prohibited from selling sensitive personal data without consent. Counsel must -work through the list before publication. - -**What happens if a threshold is crossed.** Taking revenue, or reaching 100,000 -users, converts this section from "below the threshold" to "covered", and the -obligations that arrive with it are not paperwork: a notice at collection, verified -consumer request handling with statutory deadlines, and contractual terms with -every service provider. **Treat the first paying user and the hundred-thousandth -account as legal events**, not product milestones, and re-read this section before -either happens. - -### What does not depend on a threshold, and applies today - -- **Breach notification.** See section 12. Every state has a statute, and none of - them has a revenue or volume threshold. -- **Illinois BIPA** (740 ILCS 14), which applies to a private entity of any size. - Pull.fm collects no biometric identifiers and no biometric information, which - section 3.6 states explicitly and checkably. -- **Illinois PIPA** (815 ILCS 530), which is where 312.dev LLC's own breach - notification, reasonable-security and secure-disposal duties come from, and - which likewise has no threshold. -- **Section 5 of the FTC Act.** A privacy policy is a representation. Saying - something here that the system does not do is a deceptive practice regardless - of any privacy statute, which is the reason this document carries unresolved - markers instead of confident prose. - -### Children - -See section 11. **COPPA** turns on age rather than on any threshold. - -### Complaining about us - -There is no single regulator to name. You can complain to the **Federal Trade -Commission**, to the **Illinois Attorney General** since 312.dev LLC is an -Illinois company, or to the **Attorney General of your own state**. Email -`ope@312.dev` first if you want it fixed rather than investigated. - -### Response times, stated honestly - -Pull.fm is operated by one person with no support team. The export and deletion -endpoints are **self-service and do not require us to act**, which is -deliberate: the two rights that matter most should not sit in a queue behind one -person. They are part of the service, so they are subject to the same -availability as the rest of it, and Pull.fm makes no availability commitment -(see the terms of service). If the service is unreachable, use the email route -below and we answer within the legal deadline, though not within hours. +### 10.1 What you may do at any time + +| Right | How to exercise it | +| ------------------------------ | ------------------------------------------------------------------------- | +| Obtain all of your information | "Export my data" in the application, or the equivalent request to our API | +| Delete your account | "Delete account" in the application, or the equivalent request to our API | + +Both are self-service and require no action by us. For any other request, +including correction, contact `ope@312.dev`. + +### 10.2 State privacy laws + +Pull.fm is not currently a covered business under the California Consumer Privacy +Act as amended by the California Privacy Rights Act. Civil Code section +1798.140(d)(1) requires annual gross revenues in excess of $25,000,000 as adjusted +for inflation, or the purchase, sale or sharing of the personal information of +100,000 or more consumers or households annually, or the derivation of 50 percent +or more of annual revenues from selling or sharing personal information. Pull.fm +has no revenue and has not launched. 312.dev LLC is a limited liability company, +so the non-profit exclusion is unavailable, and this position rests on the +thresholds alone. It changes if Pull.fm takes revenue or reaches 100,000 users. + +The Connecticut Data Privacy Act, as amended by Public Act 25-113 with effect from +1 July 2026, does not operate on a threshold alone. It applies to an entity that +in the preceding calendar year controlled or processed the personal data of 35,000 +Connecticut consumers, **or** controlled or processed sensitive data regardless of +volume, **or** offered personal data for sale regardless of volume. Pull.fm +processes none of the enumerated categories of sensitive data and offers no +personal data for sale. That Act also widened the definition of biometric data by +removing the requirement that it be held in order to identify a specific person; +section 3.6 disclaims biometric information absolutely and so answers the wider +definition. + +Approximately twenty states have enacted comprehensive privacy legislation. Most +set a consumer-count threshold, commonly 100,000, or a lower count combined with a +proportion of revenue derived from the sale of data, and Pull.fm is below each. +`[OPEN]` **Other than California and Connecticut, these statutes have not been +checked against their primary sources, and the position stated here is the +operator's understanding rather than a verified one.** Rhode Island sets the +lowest numeric threshold at 35,000 consumers, or 10,000 combined with 20 percent +of revenue from sale. Texas and Nebraska apply a Small Business Administration +size test rather than a numeric threshold, and an exempt small business remains +prohibited from selling sensitive personal data without consent. + +**Obligations that apply regardless of threshold.** Breach notification, which +every state imposes without a revenue or volume test; the Illinois Biometric +Information Privacy Act (740 ILCS 14), which applies to a private entity of any +size, and which section 3.6 addresses; the Illinois Personal Information +Protection Act (815 ILCS 530), from which our breach notification, reasonable +security and secure disposal duties derive; and section 5 of the FTC Act, under +which a statement in this policy that the service does not implement is a +deceptive practice. + +**Obligations we have examined and do not implement.** We do not honour Global +Privacy Control or other opt-out preference signals. The duty to do so attaches to +a business that sells or shares personal information, and Pull.fm does neither. We +make no automated decision-making disclosure under the California regulations +effective 1 January 2026, which bind businesses covered by the CCPA and are +limited to decisions concerning lending, housing, employment, education and +healthcare. We do not provide a notice at collection, categories tables, a "Do Not +Sell or Share My Personal Information" link, a "Limit the Use of My Sensitive +Personal Information" link, an authorized agent process or a 45-day response +undertaking, because each is an obligation of a covered business and asserting +them would itself be a misrepresentation. Each arrives on the day a threshold is +crossed. + +### 10.3 Complaints + +You may complain to the Federal Trade Commission, to the Illinois Attorney +General, or to the Attorney General of your own state. We ask that you contact +`ope@312.dev` first. + +### 10.4 Response times + +Pull.fm is operated by one person and has no support team. Export and deletion are +self-service and are not queued behind us. They form part of the service and are +subject to the same availability as the rest of it; Pull.fm gives no availability +commitment. Where the service is unreachable, contact us by email and we will +respond within the applicable statutory period. --- ## 11. Children -Pull.fm is not for children. The terms of service require you to be at least -**16**, and that number is kept deliberately above the line COPPA draws. - -**What the system actually does about age, stated precisely because this is a -place a policy usually overclaims.** Pull.fm **never asks your age**. There is no -date-of-birth field, no age attestation, no age checkbox and no age column in any -database migration, and sign-in is an emailed one-time code that carries no age -signal. The minimum age is therefore a **term of the contract, not a control**. -Nothing in the code could stop a younger person from creating an account, and -this document does not claim otherwise. - -What follows from that: - -- **COPPA** applies to an operator of a service directed to children under 13, or - to an operator with **actual knowledge** that it is collecting personal - information from a child under 13. Pull.fm is a music discovery service for - adults, is not directed to children by subject matter, presentation or any - other measure, and collects nothing that would tell us a user's age. So the - actual-knowledge route is the one that matters, and it is the one we can act on. -- **If we learn that an account belongs to someone under 13, we delete it**, and - the same applies under 16 as a matter of the terms. "Learn" is meant literally: - a report, or something a user tells us. We have no other way to find out. -- If you believe a child has an account, email `ope@312.dev`. - -**This description of COPPA is the operator's reading of the statute and the -rule, not advice.** The substantive claim, that no age is collected anywhere, was -checked against the migrations and the handlers rather than remembered. +Pull.fm is not intended for children. The terms of service require you to be at +least sixteen. + +Pull.fm never asks your age. We hold no date of birth, no age confirmation and no +age information of any kind, and signing in with an emailed code tells us nothing +about your age. The minimum age is therefore a term of the agreement rather than +something we can check, and nothing in the service prevents a younger person from +creating an account. + +COPPA applies to an operator of a service directed to children under thirteen, or +to an operator with actual knowledge that it collects personal information from a +child under thirteen. Pull.fm is a music discovery service for adults, is not +directed to children, and collects nothing indicating a user's age. If we learn +that an account belongs to a person under thirteen we will delete it, and the same +applies under sixteen as a term of the agreement. If you believe a child has an +account, contact `ope@312.dev`. --- ## 12. Security -The full threat model is **public** in this repository, which is unusual and -intentional: a design that only survives being secret is not a design. The -accepted-risk register was public too until 2026-07-29 and is now held privately, -for the reasons in [`../security/README.md`](../security/README.md). - -- Third-party credentials are encrypted at rest under per-record keys (section 4). -- The service is **HTTPS only**, with modern TLS and HSTS. -- Every user-scoped route is tested against an automated authorization suite that - fails the build if any route is untested. -- Secret scanning, static analysis, dependency scanning, and dynamic scanning run - in continuous integration. -- Vulnerability reports are handled under [`../SECURITY.md`](../SECURITY.md), - which includes a safe harbour for good-faith research. - -**Known limits, published rather than implied:** the operator is a single point -of failure for key custody, and the hosting account is shared with unrelated -personal services. Both are recorded, with expiry dates for re-decision, in an -accepted-risk register that is no longer public -([`../security/README.md`](../security/README.md) explains why). The limits -themselves stay published here, which is the part that concerns you: moving the -register does not retract anything this policy says. - -### If there is a breach - -**This is the obligation that survived the move to a United States posture -untouched, and it is the one with no threshold anywhere.** Every state has a -breach-notification statute, none of them cares how much revenue we make or how -many users we have, and the law that applies is the law of **your** state rather -than ours. Illinois adds its own duty on top, because 312.dev LLC is an Illinois -company: the **Personal Information Protection Act** (815 ILCS 530) requires -notification, and also imposes a standing duty to implement reasonable security -measures and to dispose of records securely. - -**We will notify you without unreasonable delay** once we know a breach has -occurred and who it affects, and we will notify the Attorneys General and any -other authority a statute names. Several states set a hard outer limit measured -in days rather than a reasonableness test, and where one applies we meet the -shortest deadline among the affected states rather than tracking each separately. -Where a breach affects data supplied by a partner whose contract requires faster -notice, we meet that shorter deadline too. - -**Two things about Pull.fm's specific data that a generic paragraph would miss:** - -- **What we hold may or may not be "personal information" as these statutes - define it, and the interesting case is a real one.** Most of them cover a name - combined with a Social Security number, a driver's licence number or a - financial account, and Pull.fm holds none of those. But the newer statutes, - California's among them, also cover **an email address in combination with a - password or credential permitting access to an online account**, and Pull.fm - holds exactly an email address alongside encrypted ListenBrainz and Last.fm - credentials. Whether a credential for a **third party's** account triggers a - statute written about the operator's own accounts is a question this document - does not pretend to settle. **We would notify rather than argue the point.** -- **Encryption is a safe harbour in most of these statutes, and here it is real - rather than nominal.** Notification is generally not required where the data - was encrypted and the key was not also acquired. Connected-service credentials - are encrypted under a key held outside the database entirely (section 4), so a - database or backup disclosure alone yields ciphertext. We would still tell you - what happened. - -**The precise state-by-state deadlines, and which of them treat our data as -covered, have not been worked through and need counsel.** What is stated above is -the structure of the obligation and the position we would take, not a compiled -matrix. +The security design of this service is published rather than kept secret. ---- +- Third-party credentials are encrypted as described in section 5. +- The service is available only over an encrypted connection. +- Every route that touches your information is covered by automated tests that + fail our build if any is left untested. +- Automated scanning for exposed secrets, code defects, vulnerable dependencies + and live vulnerabilities runs on every change. +- Vulnerability reports are handled under [`../SECURITY.md`](../SECURITY.md), + which provides a safe harbour for good-faith research. -## 13. Changes +**Known limitations.** Custody of the master encryption key rests with a single +holder. The hosting account is shared with unrelated personal services. Both are +recorded, with review dates, in a risk register held privately. -We will update this policy when the system changes. The "Last updated" date moves -and the previous version stays in this repository's public git history, so you -can see exactly what changed and when, which is a stronger guarantee than a -changelog we write about ourselves. +--- -For a **material** change we will give notice in the application before it takes -effect. +## 13. Breach notification + +We will notify you without unreasonable delay once we have determined that a +breach has occurred and whom it affects, and we will notify the Attorneys General +and any other authority named by statute. Where several states set outer limits +measured in days, we will meet the shortest deadline among the affected states. +Where an agreement with a partner requires faster notice, we will meet that +deadline. + +Every state imposes breach notification without a revenue or volume threshold, and +the applicable law is that of your state. The Illinois Personal Information +Protection Act (815 ILCS 530) applies to us additionally, and imposes a standing +duty to implement reasonable security measures and to dispose of records securely. + +Two matters specific to the information Pull.fm holds are disclosed here. First, +most breach-notification statutes define personal information as a name combined +with a Social Security number, driver's licence number or financial account +number, none of which we hold; but newer statutes, including California's, also +cover an email address combined with a credential permitting access to an online +account, and we hold an email address alongside encrypted ListenBrainz and Last.fm +credentials. Whether a credential for another company's service engages a statute +written about the operator's own accounts is unsettled. **We would notify rather +than contest the point.** Second, most of these statutes do not require +notification where the information was encrypted and the key was not also taken. +Connected-service credentials are encrypted under a key held separately, so +disclosure of our database or a backup alone yields nothing readable. We would +notify you in any event. --- -## 14. Contact +## 14. Changes -**312.dev LLC**, Illinois, United States -Privacy: `ope@312.dev` -Security: see [`../SECURITY.md`](../SECURITY.md) +We will update this policy when the service changes. The "Last updated" date will +move and previous versions remain publicly available in our published change +history. -**Contact is by email and there is no postal address published.** Section 2 sets -out what was checked before deciding that, and names the two events that would -reverse it: entering an app store, and sending a first marketing email. +For a material change we will give notice in the application before the change +takes effect, and we will ask you to accept it before you can continue to make +changes to your account. We do not treat continued use as acceptance. --- -## Appendix: open items blocking publication - -A checklist, so that nothing above is quietly published while still untrue. - -| # | Item | Section | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | -| 1 | **The retention jobs are built, tested and scheduled, and the schedule has never fired** because no compute is deployed to run it. The windows in sections 7 and 8 are enforced on each run, and a run is presently started by hand. A schedule must be verified to have fired before those windows are stated as unqualified promises. | 7, 8 | -| 2 | No log retention period is configured anywhere. A number must exist in the system before it is stated here. | 8 | -| 3 | The 35-day lifecycle rule on the logical dumps is configured but **we cannot read it**, because the backup credential is bucket-scoped and lifecycle reads are a bucket-admin operation. Run the conformance check from an operator credential. The Neon PITR window itself is settled at **7 days**, read back from the live project on 2026-07-29; it was 6 hours on the retired free-plan project and this row said so until then. | 7, 8 | -| 4 | ~~No written security commitments are on file with **Neon or Hetzner**.~~ **Resolved 2026-07-30:** all four are in writing and filed as dated documents in the operator's vault under `pull-fm/legal/`, addressed by title and deliberately not copied into this public repository. Neon executed by both parties, Hetzner executed at version 1.2, WorkOS and Cloudflare binding by their own terms with the Cloudflare agreement confirmed from the billing page. **What remains open is narrower and is now item 4a:** the two statutory citations the obligation rests on. | 9 | -| 4a | **The Massachusetts 201 CMR 17.03(2)(f) and New York SHIELD Act citations in section 9 are the operator's reading and have not been checked against the primary sources.** Four processor records now rest on them. Counsel must confirm both, and whether any other state imposes the same duty. | 9 | -| 5 | ~~The registration refusal for the EEA, the UK and Switzerland does not exist.~~ **Resolved 2026-07-29:** `apps/bff/src/lib/registration-geo.ts` holds the list, three auth routes enforce it before the identity provider is called, and 40 tests across two suites cover it. Its honest limits are described in section 9 and recorded in the private risk register, not left as a gap here. | 2, 9 | -| 6 | ~~Controller's state of organisation, postal address, and governing supervisory authority are unfilled.~~ **Resolved:** 312.dev LLC is organised in Illinois. The postal address is deliberately not published and section 2 records what was checked; the supervisory-authority row is gone with the GDPR. | 2, 10 | -| 7 | ~~**The infrastructure has not moved.**~~ **Resolved 2026-07-29:** it moved, and the European estate was deleted rather than left running. The database region was confirmed as `aws-us-east-1` by reading the live project back from the provider API; the application node is in Ashburn; the object storage carries no jurisdiction. **The residual is now item 7d**, which is about what pins those placements rather than what they are. | 9 | -| 7a | The **erasure-ledger bucket is not described by infrastructure code at all**, so nothing fixes or checks its placement the way the backup bucket's is fixed and checked. | 9 | -| 7d | **The application node's location is applied from an operator file that is not committed, and the checked-in default for it is still a European site.** Nothing in section 9 is false today; a fresh apply that forgot the operator file would make it false. Point the committed default at the site the table names. | 9 | -| 7b | ~~Confining TLS termination to the EU needs Cloudflare Regional Services.~~ **Not applicable.** That was a Chapter V mitigation for an EU transfer. The edge is still global and section 9 still discloses it, but there is no longer a safeguard it is failing to provide. | 9 | -| 7c | **The state comprehensive privacy laws other than California were not checked against their statutes.** Texas and Nebraska first, because they use an SBA size test rather than a numeric threshold. | 10 | -| 8 | ~~Expired `idempotency_keys` and `connect_states` rows are never deleted.~~ **Resolved:** `sweep:expired` deletes both an hour past expiry. Scheduling it is item 1. | 8 | -| 9 | ~~The sign-in methods sentence disagrees with `docs/PLAN.md` section 4.~~ **Resolved:** `docs/PLAN.md` section 4a records magic link only, and a test and a database constraint enforce it. `legal/terms-of-service.md` section 4 still described social sign-in when this row was first marked resolved, and was corrected on 2026-07-29; check the sibling documents, not only the plan, before closing a row of this kind again. | 3.1 | -| 10 | The WorkOS subprocessor list has not been read and summarized. | 9 | -| 11 | WorkOS may use personal data "to build or improve the quality of its services", and their addendum is silent on AI/ML training. Decide whether to seek a commitment. | 3.1, 9 | -| 12 | ~~Terraform still describes the pre-Neon self-hosted database.~~ **Resolved** as to the self-hosted layout, and as of 2026-07-29 as to the region as well; see item 7. | 9 | -| 13 | This document has not been reviewed by a lawyer, and the rewrite to a United States posture makes that more pressing rather than less: it replaced one settled body of law with several unsettled ones, and the reasoning in sections 9, 10 and 12 is the operator's. | all | -| 14 | The territorial posture is now enforced, and its two residual weaknesses are **in the private risk register rather than here**, because neither is a gap between what this document claims and what the system does: address-based geolocation can be evaded, and the country header is trusted on the strength of a zone-level origin-pull certificate. Section 9 already declines to claim more than the control delivers. | 2, 9 | +## 15. Contact + +312.dev LLC, Illinois, United States +Privacy: `ope@312.dev` +Security: [`../SECURITY.md`](../SECURITY.md) + +Contact is by email. No postal address is published; see section 2. diff --git a/legal/terms-of-service.md b/legal/terms-of-service.md index b8ce2a4..c06f0ca 100644 --- a/legal/terms-of-service.md +++ b/legal/terms-of-service.md @@ -1,72 +1,12 @@ # Pull.fm Terms of Service and Application End User Licence Agreement -> # THE VOID CLAUSE IS FIXED. +> **DRAFT. Not yet effective. Has not been reviewed by counsel.** > -> **Section 16 now selects a law and a forum.** 312.dev LLC is organised in -> **Illinois**, supplied by the operator on 2026-07-29, so the governing-law and -> venue placeholders are filled and the dispute framework that the warranty -> disclaimer (11), the liability cap (13) and the indemnity (14) rest on is no -> longer resting on nothing. Section 1 carries the same fact. -> -> **Section 2 now claims a territorial refusal, and the claim was checked before -> it was written.** `apps/bff/src/lib/registration-geo.ts` holds the country -> list, three authentication routes enforce it before the identity provider is -> contacted, and two test suites cover it. Section 2 says what the control does -> and, just as deliberately, what it does not: it refuses account creation rather -> than blocking access, and it is address-based geolocation rather than proof of -> residence. A terms document that said "United States residents only" while -> European sign-ups succeeded would be a false statement in a binding agreement, -> which is worse than the gap it was meant to cover, so the sentence was written -> only after the code was read. -> -> **Before publishing, run:** -> -> ```bash -> node legal/check-publication-blockers.mjs # or: make legal -> ``` -> -> It exits non-zero while any placeholder remains and prints each one with a -> file and line. - -> # DRAFT - NOT LEGAL ADVICE, REQUIRES REVIEW -> -> This document was drafted by the operator against the actual behaviour of the -> code in this repository and against the upstream terms recorded in -> [`../docs/UPSTREAM-TERMS.md`](../docs/UPSTREAM-TERMS.md) and -> [`../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md`](../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md). -> **It has not been reviewed by a lawyer.** It must be before it is published at -> a stable URL or presented to any user, and in particular sections 3, 11, 12, -> 13, and 16 are the ones where a non-lawyer draft is most likely to be wrong or -> unenforceable. -> -> **Placeholders marked `[CONFIRM]` require an operator or counsel decision** and -> must not survive into a published version. -> -> **Why this document is a hard blocker rather than paperwork:** SeatGeek's API -> Terms of Use clause 4.3 requires -> -> > an Application EULA that the Application displays and that each End User must -> > accept before using it, containing terms - expressly including warranty -> > disclaimers and limitations of liability - **at least as protective of the -> > SeatGeek Entities as SeatGeek's own API Terms**, complying with any -> > third-party app-store requirements, and **expressly designating the SeatGeek -> > Entities as third-party beneficiaries entitled to enforce it against End -> > Users directly**; plus (i) all reasonable efforts to enforce it and (ii) no -> > action on behalf of, collection of information from or regarding, or device -> > access for any End User without that End User's affirmative authorisation. -> -> Until that exists, Pull.fm may not ship live event data at all. Sections 8, 9, -> 11, 13 and 14 are that clause discharged, and the third bullet of section 13 is -> where "at least as protective" became a specific number rather than an -> aspiration. -> -> **That wording is quoted, not summarised, and it must stay identical wherever -> it appears.** The full verbatim clause is at -> [`../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md`](../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md), -> which also records why: until 2026-07-29 this repository rendered the same -> requirement three different ways in three documents, and the one being relied on -> was the shortest of the three, which had dropped the protectiveness standard -> entirely. +> These Terms have not been published at a stable URL and no user has accepted +> them. Outstanding items, including the counsel questions on sections 9 and 13, +> are tracked in +> [`../docs/compliance/publication-checklist.md`](../docs/compliance/publication-checklist.md) +> and must be closed before publication. **Version:** DRAFT-1 (unpublished) **Last updated:** 2026-07-30 @@ -74,432 +14,275 @@ --- -## 1. Who we are and what this covers +## 1. Who we are and what these Terms cover Pull.fm ("**Pull.fm**", "**we**", "**us**") is a music discovery service operated by **312.dev LLC**, a limited liability company organised under the laws of the -**State of Illinois**, United States. - -These Terms are a binding agreement between you and 312.dev LLC. They cover: +State of Illinois, United States. -- the Pull.fm backend API at `api.pull.fm`; -- the Pull.fm client applications distributed as signed GitHub Release assets; -- any personal API token you create. +These Terms are a binding agreement between you and 312.dev LLC. They cover the +Pull.fm backend API at `api.pull.fm`, the Pull.fm client applications distributed +as signed GitHub Release assets, and any personal API token you create. By creating an account, installing a Pull.fm client, or using the API, you agree to these Terms. If you do not agree, do not use Pull.fm. -`[OPEN]` **That sentence is still a claim rather than a mechanism, but the gap is -now half the size it was, and the half that remains is named precisely rather -than left as "build a consent gate".** Distribution is a sideloaded app file from -GitHub Releases, so there is no store flow and no installer dialogue in which a -user is shown terms and acts on them. - -**What now exists, server-side, and can be relied on:** these documents carry -machine-readable versions and a content digest; the API records who accepted -which version of which document, when, from which session and on which client -build, in an append-only table an UPDATE cannot rewrite; a **material** revision -raises a consent epoch and every user must accept again, while a corrected typo -does not; the API reports what an authenticated user still owes at -`GET /v1/me/consent` and records an acceptance at `POST /v1/me/consent`; and an -authenticated user who has accepted **nothing** is refused every route except -signing out, reading their own account, the consent endpoints themselves, and the -data-subject rights in sections 15 and 19 (export and deletion), which are never -conditioned on accepting these Terms. A user who has accepted an earlier epoch -keeps their read access and is refused writes until they accept the current one. -The record is held server-side rather than in app storage, so it survives a -reinstall. - -**What still does not exist, and is what keeps this marker open:** no client -presents the documents. Nothing above obtains assent by itself - a server that -records an acceptance it was told about cannot know that a human was shown -anything - and under Illinois law the interface is the whole question. Two -further things are outstanding and both are prerequisites for the client half: -these documents are not yet published at a stable URL, and the client must fetch -the **canonical document bytes** and echo their digest when it accepts, because -the API refuses to record an acceptance whose digest does not match the version it -publishes. Publishing a rendered page whose bytes differ from the canonical source -would make acceptance impossible rather than merely inconsistent. - -Under Illinois law that matters more than the wording of any individual clause. -In `Sgouros v. TransUnion Corp.`, 817 F.3d 1029 (7th Cir. 2016), no contract was -formed even where the user completed a **paid** purchase on a page that displayed -the terms, because the interface did not communicate that proceeding was assent. -A sideloaded application with no consent step has a weaker record than that, not -a stronger one. - -**If no contract forms, nothing here binds.** Not the limitation of liability in -section 13, not the Illinois governing-law and venue selection in section 16, not -the third-party beneficiary grant in section 9, and not the US-only offering in -section 2. The careful drafting of each is contingent on assent that the product -does not currently obtain. - -The remedy is a first-launch consent gate in the client: present these Terms and -the privacy policy, require an affirmative action, and record what was accepted -and when. **The recording half is built** (`packages/db/migrations/0008_legal_consent.sql`, -`apps/bff/src/lib/legal-documents.ts`, `apps/bff/src/routes/v1/consent.ts`). The -**presenting** half is one screen in a client application that does not exist yet, -and it is worth more than any clause in this document, so it is recorded here -rather than left to be discovered after launch. Editing either of these documents -without deciding whether the change is material is a build failure, not a silent -drift, so the versions this marker relies on cannot rot. - -**There is now a second, independent, contractual reason for the same screen, and -it was found on 2026-07-29 when SeatGeek's terms were read in full rather than in -paraphrase.** Their clause 4.3 does not merely require that an Application EULA -exist. It requires that the Application **displays** it and that each End User be -**required to accept it before using the Application**, and it separately obliges -us to "use all reasonable efforts to enforce" it and to ensure the Application -"collects any information from or regarding any End User" only where that End User -has "affirmatively authorized or directed" it. So the missing screen is not only a -contract-formation weakness under Illinois law; on the day live events are enabled -it is a **breach of the SeatGeek agreement in three places at once**, and one of -those places (the affirmative-authorisation duty) is not limited to SeatGeek data -at all. Enabling events without it would be worse than leaving events disabled. - -**The recording half described above is what makes 4.3(i) dischargeable at all.** -"Use all reasonable efforts to enforce the Application EULA" is not something a -document can do; it needs a system that knows who accepted which version and that -refuses service to someone who has accepted nothing. That system now exists. What -it cannot do is manufacture the acceptance it records, which is the whole of the -remaining gap and the reason this marker stays open. - -**Pull.fm is free and non-commercial.** We charge nothing, we sell nothing, we -run no advertising, we take no affiliate or referral revenue, and we do not sell -or share your personal information. This is a locked product constraint recorded -in [`../docs/PLAN.md`](../docs/PLAN.md) section 1a, not a launch promotion. It is -also a condition of several of the upstream licences Pull.fm depends on, so it -cannot change without those licences being renegotiated first. - -**Pull.fm is operated by one person.** Section 12 states what that means for -availability in terms we can actually keep. +Pull.fm is free and non-commercial. We charge nothing, we sell nothing, we run no +advertising, we take no affiliate or referral revenue, and we do not sell or +share your personal information. This is a permanent product constraint and a +condition of several of the upstream licences on which Pull.fm depends. + +Pull.fm is operated by one person. Section 12 states what that means for +availability. --- ## 2. Eligibility, where Pull.fm is offered, and your account -**Pull.fm is offered to residents of the United States.** We **refuse to create -an account** for anyone who appears to be in the **European Economic Area** (the -27 EU member states plus Iceland, Liechtenstein and Norway), the **United -Kingdom**, or **Switzerland**. Those three are named separately because they are -three separate legal regimes rather than one: the UK has had its own data -protection law since Brexit, and Switzerland was never in the EEA. The refusal -also covers **European Union territory that is reported under its own country -code** rather than its member state's, which is why a sign-up from Reunion or the -Aland Islands is refused as well. `privacy-policy.md` section 9 sets out why this -matters and what it means for which law applies to you. - -Four things about that refusal, stated precisely because each of them is a place -this kind of clause usually overstates itself: - -- **It refuses registration, not access.** Pull.fm is not switched off in those - places. If you already have an account it keeps working, your session still - refreshes, you can still sign out, nothing was deleted, and the rest of the API - answers normally. What is refused is the creation of a new account. +**Pull.fm is offered to residents of the United States.** We refuse to create an +account for anyone who appears to be in the European Economic Area (the 27 EU +member states together with Iceland, Liechtenstein and Norway), the United +Kingdom, or Switzerland. The refusal extends to European Union territory reported +under its own country code rather than that of its member state. + +The following describes the limits of that refusal: + +- **It refuses registration, not access.** Pull.fm is not withdrawn in those + places. An existing account continues to work, sessions continue to refresh, + and nothing is deleted. What is refused is the creation of a new account. - **A refused attempt creates nothing and sends nothing.** The check runs before we contact our identity provider, so no account is created, no record of your - address is created at that provider, and no sign-in email is sent to you. You - get an immediate explanation instead. -- **It is decided from the network address your request arrives from**, as - reported by our content delivery network. That is the ordinary way this is - done and it is what we can offer, but it is not perfect: a virtual private - network or a mis-located address can defeat it in either direction. **We do not - claim that no resident of those regions can hold an account.** We claim that we - do not knowingly open one, that we take reasonable measures not to, and that - this is our stated intent. -- **If we cannot determine where a request comes from, we refuse it.** That - includes requests over Tor. It is deliberate, because the alternative would - make the whole thing optional. - -You must be at least **16 years old** to use Pull.fm. We do not knowingly -provide the service to anyone younger. If we learn that an account belongs to -someone under 16, we will delete it. **Pull.fm never asks your age**, so this is -a term you agree to rather than something we check; -[`privacy-policy.md`](privacy-policy.md) section 11 states that plainly and says -what follows from it. - -Accounts are created through **WorkOS AuthKit**, using an emailed one-time code -("magic link"). That is the whole sign-in surface: social sign-in, passkeys and -passwords are not enabled. **Pull.fm issues no passwords and stores no password -hashes**, by design. `legal/privacy-policy.md` section 3.1 describes the same -surface and the constraints that keep it that way. - -You are responsible for the security of the identity provider account you sign -in with, and for any personal API token you create. A personal API token is a -bearer credential: anyone holding it can read your Pull.fm data within its -scopes until it expires or you revoke it. + address is created at that provider, and no sign-in email is sent. +- **It is determined from the network address the request arrives from**, as + reported by our content delivery network. It is not proof of residence, and a + virtual private network or a mis-located address can defeat it in either + direction. We do not represent that no resident of those regions holds an + account. We represent that we do not knowingly create one and that we take + reasonable measures to that end. +- **Where the origin of a request cannot be determined, the request is refused.** + This includes requests over Tor. + +You must be at least **16 years old** to use Pull.fm. We do not knowingly provide +the service to anyone younger, and if we learn that an account belongs to a +person under 16 we will delete it. Pull.fm never asks your age, so this is a term +you agree to rather than a matter we verify; +[`privacy-policy.md`](privacy-policy.md) section 11 sets out what follows. + +Accounts are created through WorkOS AuthKit using an emailed one-time code. +Social sign-in, passkeys and passwords are not enabled. Pull.fm issues no +passwords and stores no password hashes. + +You are responsible for the security of the identity provider account you sign in +with, and for any personal API token you create. A personal API token is a bearer +credential: any person holding it may read your Pull.fm data within its scopes +until it expires or you revoke it. --- -## 3. The software, and the service, are licensed differently +## 3. The software and the service are licensed separately -**The software is Apache-2.0. We do not license it to you here, and we cannot -take it back.** +**The software is licensed to you under the Apache License 2.0, and these Terms +do not restrict that licence.** The Pull.fm source code and the compiled client applications we publish are both -licensed under the **Apache License 2.0**. That licence is perpetual, -irrevocable, worldwide, royalty-free, and transferable, and it permits -commercial use. Nothing in these Terms limits it, and nothing in these Terms -should be read as an attempt to. You may keep, copy, modify, redistribute and -sell the application under Apache-2.0, including a version pointed at a server -that is not ours. - -An earlier draft of this section granted a "non-transferable, revocable licence" -covering "the hosted service and the compiled client binaries, which the Apache -licence does not". That was wrong on the facts: the binaries **are** distributed -under Apache-2.0. A revocable licence to something already granted irrevocably -is not a restriction, it is a contradiction, and publishing it would have told -you that you had fewer rights than you do. - -**What these Terms actually govern is access to the hosted service:** the Pull.fm -API, the accounts it holds, and the data it returns. That access is personal, +licensed under the Apache License 2.0. That licence is perpetual, irrevocable, +worldwide, royalty-free and transferable, and it permits commercial use. Nothing +in these Terms limits it. You may keep, copy, modify, redistribute and sell the +application under Apache-2.0, including a version directed at a server other than +ours. + +**These Terms govern access to the hosted service:** the Pull.fm API, the +accounts it holds and the data it returns. That access is personal, non-transferable and revocable, and it is offered to residents of the United -States (see section 2). +States under section 2. -The distinction is not academic, so here is the practical shape of it: +Accordingly, we may suspend or terminate your account and API access, and we +neither can nor claim to prevent you from running the application. On termination +of access, your copy of the application remains yours under Apache-2.0 and simply +has no Pull.fm server to address. -- We can suspend or terminate your **account and API access**, and this section - is the basis on which we do. -- We cannot, and do not claim to, stop you **running the app**. If we terminate - your access, your copy of the application remains yours under Apache-2.0; it - simply has no Pull.fm server to talk to. - -The conditions below are therefore conditions of using **the hosted service**, -not conditions of your licence to the software. The consequence of breaching one -is losing service access. Several of them exist because our upstream data -providers impose them on us, and we cannot pass on rights we do not hold -(see section 8 and [`attribution.md`](attribution.md)). +The conditions in this section are conditions of using the hosted service and not +conditions of your licence to the software. Several exist because our upstream +data providers impose them on us and we cannot sublicense rights we do not hold. When using the hosted service, you may not: - use the hosted service, or data obtained from it, for any commercial purpose. - Note the boundary: Apache-2.0 permits commercial use of the **software** and - this does not restrict that. What is restricted is commercial use of **our - service and the data it returns**, because our upstream providers licence that - data to us for non-commercial use and we cannot sublicense more than we hold; + Apache-2.0 permits commercial use of the software and this restriction does not + affect that; what is restricted is commercial use of the service and the data it + returns, which our upstream providers licence to us for non-commercial use only; - resell, sublicense, or provide Pull.fm's output as a service to others; -- remove, obscure, alter, or fail to render any attribution, credit, logo, or - link that Pull.fm supplies with data (see section 8 and +- remove, obscure, alter or fail to render any attribution, credit, logo or link + that Pull.fm supplies with data (see section 7 and [`attribution.md`](attribution.md)); -- circumvent rate limits, quotas, or access controls; -- scrape, bulk-download, or systematically extract data from Pull.fm; -- use Pull.fm to build or train a machine learning model, or to populate a - search engine, directory, dataset, or index; -- probe, scan, or test the security of the service other than as permitted by - our [security policy](../SECURITY.md), which includes a safe harbour for good - faith research. - -Resolved 2026-07-29: the owner confirmed the compiled client binaries **are** -distributed under Apache-2.0, so this section was rewritten to govern the hosted -service alone. +- circumvent rate limits, quotas or access controls; +- scrape, bulk-download or systematically extract data from Pull.fm; +- use Pull.fm to build or train a machine learning model, or to populate a search + engine, directory, dataset or index; +- probe, scan or test the security of the service other than as permitted by our + [security policy](../SECURITY.md), which provides a safe harbour for good-faith + research. --- ## 4. Personal API tokens -You may create read-only personal API tokens to access **your own** data. +You may create read-only personal API tokens to access your own data. - Tokens are read-only and scoped. They cannot delete your account, export your - personal data, create or list other tokens, modify your wishlist, or touch + personal data, create or list other tokens, modify your wishlist, or affect your connected third-party accounts. -- Every token expires (default 90 days, maximum 365). You may revoke or rotate - one at any time; revocation takes effect on the next request. -- You are limited to 10 live tokens. -- We store only a SHA-256 digest of the token, so we cannot recover or re-display - it. If you lose it, rotate it. +- Every token expires, by default after 90 days and at most after 365 days. You + may revoke or rotate a token at any time; revocation takes effect on the next + request. +- You may hold at most 10 live tokens. +- We store only an irreversible fingerprint of a token and cannot recover or + re-display it. -**Tokens do not grant a data licence.** The rules in sections 3, 7, and 8 apply -in full to anything you retrieve with a token. In particular, feeding -token-retrieved data into a machine learning system, a public dataset, or a -search index is prohibited. +Tokens grant no data licence. Sections 3, 7 and 8 apply in full to anything +retrieved with a token, and in particular the prohibitions on machine learning +use, public datasets and search indexing. -**Live event data is not available through a personal API token**, and this is a -contractual restriction rather than a product choice. See section 8. +Live event data is not available through a personal API token. This is a +contractual restriction; see section 8. --- ## 5. Connecting third-party accounts -Pull.fm can connect to your **ListenBrainz** and **Last.fm** accounts to produce -recommendations. When you connect one, you authorise us to store a credential -for that service and to use it on your behalf for that purpose. +Pull.fm can connect to your ListenBrainz and Last.fm accounts to produce +recommendations. When you connect one, you authorise us to store a credential for +that service and to use it on your behalf for that purpose. -- Those credentials are encrypted at rest (AES-256-GCM envelope encryption) and - are never included in a data export, a log, an error message, or a support - transcript. [`privacy-policy.md`](privacy-policy.md) section 5 explains why the - export excludes them. +- Credentials are encrypted using AES-256 under a key held separately from them, + and are never included in a data export, log, error message or support record. - You may disconnect a service at any time, which deletes our stored credential. -- Your relationship with ListenBrainz and Last.fm is governed by **their** terms, - not ours. We are not responsible for their services, their availability, or - what they do with your data on their side. -- **Revoke at the source too.** Disconnecting at Pull.fm deletes our copy of the - credential. If you also want the credential itself invalidated, revoke it in - your account settings at that provider. Last.fm session keys in particular do - not expire on their own. +- Your relationship with ListenBrainz and Last.fm is governed by their terms. We + are not responsible for their services, their availability, or their handling + of your data. +- **Revoke at the source as well.** Disconnecting at Pull.fm deletes our copy of + the credential. To invalidate the credential itself, revoke it in your account + settings at that provider. Last.fm session keys do not expire. --- ## 6. Your content -You own what you put into Pull.fm (wishlist entries, notes, display name). You -grant us only the licence needed to operate the service for you: to store, -process, and display that content back to you. +You own what you put into Pull.fm, including wishlist entries, notes and your +display name. You grant us only the licence necessary to operate the service for +you: to store, process and display that content back to you. We do not publish your data, sell it, share it with advertisers, or use it to -train models. There is no public profile, no social feed, and no sharing surface. +train models. There is no public profile, no social feed and no sharing surface. --- ## 7. Data from third-party sources -Pull.fm assembles data from **ListenBrainz, MusicBrainz, Last.fm, Apple/iTunes, -Deezer, and SeatGeek**. That data belongs to those providers, is licensed to us -on limited terms, and is provided to you subject to the same limits. +Pull.fm assembles data from ListenBrainz, MusicBrainz, Last.fm, Apple/iTunes, +Deezer and SeatGeek. That data belongs to those providers, is licensed to us on +limited terms, and is provided to you subject to the same limits. You agree that: -- you will not copy, redistribute, republish, sell, or systematically store data +- you will not copy, redistribute, republish, sell or systematically store data obtained from Pull.fm that originates with any of those providers; -- you will not use it to build a competing catalogue, dataset, index, or +- you will not use it to build a competing catalogue, dataset, index or directory; -- you will not use it to train, fine-tune, ground, or evaluate a machine - learning model; -- you will render every attribution, credit, logo, and link that Pull.fm - supplies alongside the data, unmodified except as expressly permitted; -- **you will not add affiliate, referral, or tracking parameters** to any - outbound link Pull.fm provides. Doing so breaches the Last.fm, Deezer, and - Apple terms simultaneously and would end Pull.fm's access to all three. - -Music previews are provided by **Apple/iTunes** and **Deezer** and are for -preview purposes only. You may not download, record, save, cache, or -redistribute preview audio. +- you will not use it to train, fine-tune, ground or evaluate a machine learning + model; +- you will render every attribution, credit, logo and link that Pull.fm supplies + alongside the data, unmodified except as expressly permitted; +- **you will not add affiliate, referral or tracking parameters** to any outbound + link Pull.fm provides. Doing so would breach the Last.fm, Deezer and Apple + terms simultaneously and would end Pull.fm's access to all three. + +Music previews are provided by Apple/iTunes and Deezer for preview purposes only. +You may not download, record, save, cache or redistribute preview audio. If a provider terminates or restricts our access, the corresponding part of -Pull.fm stops working. We have no control over that and no obligation to +Pull.fm ceases to function. We have no control over that and no obligation to replace it. --- ## 8. Live event data and SeatGeek -**This section is required by SeatGeek's API Terms of Use clause 4.3 and its -restrictions bind you directly.** +Live event data is **not served at present** and no events provider is enabled. +This section must be in force before the first event is served. -**Current status, stated so this section is not read as a description of a -feature that exists.** Live event data is **not served today**: -`GET /v1/artists/{mbid}/events` returns HTTP 501 and no events provider is -enabled on this deployment. This section exists because SeatGeek clause 4.3 -makes an Application EULA containing these terms a **precondition of enabling it -at all**, and because the terms must be in force before the first event is -served rather than after. +When enabled, live event information in Pull.fm is supplied by SeatGeek +("**SeatGeek Materials**"). In addition to section 7, you specifically agree that +you will **not**: -When enabled, live event information in Pull.fm is supplied by **SeatGeek** -("**SeatGeek Materials**"). In addition to everything in section 7, you specifically agree -that you will **not**: - -1. **systematically download, scrape, harvest, or store** SeatGeek Materials, in - whole or in part, whether manually or by automated means; -2. make SeatGeek Materials available to, or use them in connection with, **any +1. systematically download, scrape, harvest or store SeatGeek Materials, in whole + or in part, whether manually or by automated means; +2. make SeatGeek Materials available to, or use them in connection with, any search engine, directory, dataset, index, or any artificial intelligence or - machine learning application or model**, including by pasting them into or + machine learning application or model, including by entering them into or retrieving them for such a system; -3. use SeatGeek Materials for any **competitive purpose**, including price +3. use SeatGeek Materials for any competitive purpose, including price comparison, ticket aggregation, market analysis, or operating or supporting a - **secondary ticket marketplace**; -4. remove, obscure, alter, crop, recolour, or otherwise modify the **SeatGeek - logo** or its link, or display SeatGeek Materials anywhere the logo is not - displayed and linked to . Proportional resizing of the - logo is the only modification permitted; -5. display, invent, estimate, or infer **ticket prices** in connection with - SeatGeek Materials. SeatGeek supplies none; -6. use SeatGeek Materials for any **commercial purpose**, or in any way that + secondary ticket marketplace; +4. remove, obscure, alter, crop, recolour or otherwise modify the SeatGeek logo + or its link, or display SeatGeek Materials anywhere the logo is not displayed + and linked to . Proportional resizing of the logo is the + only modification permitted; +5. display, invent, estimate or infer ticket prices in connection with SeatGeek + Materials. SeatGeek supplies none; +6. use SeatGeek Materials for any commercial purpose or in any manner that generates revenue for you; -7. submit **personal data** (including precise location coordinates, postal - codes, email addresses, or names of other people) through any Pull.fm feature - that queries live events. Pull.fm's events interface accepts a city name only, - and the backend rejects coordinate-shaped input; -8. use SeatGeek Materials in any way that would breach SeatGeek's own published - terms if you were their direct licensee. +7. submit personal data, including precise location coordinates, postal codes, + email addresses or the names of other people, through any Pull.fm feature that + queries live events. Pull.fm's events interface accepts a city name only and + the service rejects coordinate-shaped input; +8. use SeatGeek Materials in any manner that would breach SeatGeek's own + published terms were you their direct licensee. **Access restrictions we enforce.** SeatGeek-derived data is not exposed through personal API tokens, is not included in a data export, is not served on any unauthenticated or public route, and is not included in any feed cached or -indexable by a crawler. If you find a way to reach it through one of those paths, -that is a bug and we ask you to report it under our -[security policy](../SECURITY.md). - -**No warranty on events.** Event data is provided by SeatGeek "as is". Times, -venues, line-ups, and availability change. Confirm with the venue or SeatGeek -before relying on anything. Our own agreement with SeatGeek caps their total -liability to us at **fifty United States dollars**, so nothing in Pull.fm is -designed to depend on their availability, and neither should anything you do. -Section 13 applies the same figure to any claim you might have against them -through us. +indexable by a crawler. Any route by which it can be reached is a defect and we +ask that you report it under our [security policy](../SECURITY.md). + +**No warranty on events.** Event data is provided by SeatGeek as is. Times, +venues, line-ups and availability change. Confirm with the venue or with SeatGeek +before relying on any of it. Section 13 states the limit of liability applicable +to the SeatGeek Entities. --- ## 9. Third-party beneficiaries -**SeatGeek, Inc. and its affiliates, subsidiaries, parents, successors, and +**SeatGeek, Inc. and its affiliates, subsidiaries, parents, successors and assigns (collectively, the "SeatGeek Entities") are express third-party beneficiaries of these Terms.** -The SeatGeek Entities are entitled to **enforce these Terms directly against -you**, in their own name, with respect to any provision that concerns SeatGeek -Materials, including without limitation section 7, section 8, section 11 -(disclaimer of warranties), section 13 (limitation of liability), and section 14 -(indemnity). No consent from the SeatGeek Entities is required for any amendment -of these Terms, but no amendment may reduce the protections these Terms afford -the SeatGeek Entities below those in SeatGeek's own API Terms of Use. - - +The SeatGeek Entities are entitled to enforce these Terms directly against you, +in their own name, with respect to any provision concerning SeatGeek Materials, +including without limitation section 7, section 8, section 11 (disclaimer of +warranties), section 13 (limitation of liability) and section 14 (indemnity). No +consent of the SeatGeek Entities is required for any amendment of these Terms, +but no amendment may reduce the protections these Terms afford the SeatGeek +Entities below those in SeatGeek's own API Terms of Use. Except as stated in this section, these Terms create no rights in any person who is not a party to them. -`[CONFIRM with counsel: that this clause is drafted so as to be effective under Illinois law, which section 16 now selects, and that "at least as protective of the SeatGeek Entities as the terms hereof" under SeatGeek clause 4.3 is satisfied by the combination of sections 7, 8, 9, 11, 13, and 14. Three specific questions, now that the SeatGeek terms have been read rather than paraphrased. First, whether the USD 50 cap in the third bullet of section 13 discharges 4.3's express reference to "limitations of liability" given that SeatGeek's own clause 8.2 caps them at exactly USD 50, so we match rather than better it. Second, whether "at least as protective" reaches CONSPICUOUSNESS as well as substance: their 8.2 is in capitals, ours now is too for the SeatGeek bullet only, and under Illinois law a limitation of liability must be conspicuous to be enforceable at all. Third, whether section 9 plus section 13 together survive Sosa v. Onfido, 8 F.4th 631, which turned on a third party falling outside the defined class the limitation protected. This is the specific judgement a non-lawyer cannot make and it is the whole point of the clause.]` - --- ## 10. Acceptable use -Do not: +You may not: -- access another person's account or data, or attempt to; -- interfere with, overload, or degrade the service or its infrastructure; -- use Pull.fm to violate any law, or any third party's rights; -- misrepresent yourself, or use Pull.fm to impersonate anyone; -- automate the service beyond your own personal use, or run it as a shared +- access another person's account or data, or attempt to do so; +- interfere with, overload or degrade the service or its infrastructure; +- use Pull.fm to violate any law or any third party's rights; +- misrepresent yourself, or use Pull.fm to impersonate any person; +- automate the service beyond your own personal use, or operate it as a shared backend for other people. -We may suspend or terminate access for a breach of this section, immediately and +We may suspend or terminate access for breach of this section, immediately and without notice where the breach threatens the service, other users, or our upstream licences. @@ -507,44 +290,41 @@ upstream licences. ## 11. Disclaimer of warranties -**Pull.fm is provided "as is" and "as available", with no warranty of any kind.** -To the maximum extent permitted by law, we and our suppliers (including the -SeatGeek Entities and every other upstream provider named in section 7) disclaim -all warranties, express, implied, or statutory, including any implied warranty of -merchantability, fitness for a particular purpose, title, accuracy, and +**Pull.fm is provided "as is" and "as available", without warranty of any kind.** +To the maximum extent permitted by law, we and our suppliers, including the +SeatGeek Entities and every other upstream provider named in section 7, disclaim +all warranties, express, implied or statutory, including any implied warranty of +merchantability, fitness for a particular purpose, title, accuracy and non-infringement. -We do not warrant that Pull.fm will be available, uninterrupted, timely, secure, -or error-free, or that any data it shows you is accurate or complete. Metadata, -recommendations, previews, and event listings come from third parties and are -frequently wrong in ordinary ways. +We do not warrant that Pull.fm will be available, uninterrupted, timely, secure +or error-free, or that any data it displays is accurate or complete. Metadata, +recommendations, previews and event listings originate with third parties and are +frequently inaccurate. -Some jurisdictions do not allow the exclusion of implied warranties, so parts of +Some jurisdictions do not permit the exclusion of implied warranties, so parts of this section may not apply to you. --- -## 12. Availability, and the honest service level - -**Pull.fm makes no availability commitment.** There is no SLA, no uptime -guarantee, no support commitment, and no 24/7 response. +## 12. Availability -Stated plainly, because a promise nobody can keep is worse than no promise: +**Pull.fm gives no availability commitment.** There is no service level +agreement, no uptime guarantee, no support commitment and no 24-hour response. -- Pull.fm is operated by **one person**, with no on-call rotation. -- Alerts do not page a human. The system is designed to **degrade automatically** - rather than to summon someone: restart policies, an external health check that - can enable a maintenance mode, and a read-only degraded mode. -- Outages may last hours or, during a "vacation mode" freeze, longer. +- Pull.fm is operated by one person, with no on-call rotation. +- Alerts do not page a person. The service is designed to degrade automatically + by means of restart policies, an external health check that can enable a + maintenance mode, and a read-only degraded mode. +- Outages may last hours, or longer during a scheduled freeze. - Security reports are triaged on the timetable published in - [`../SECURITY.md`](../SECURITY.md), which is measured in business days. -- We may change, suspend, or discontinue any part of Pull.fm, including the whole - of it, at any time and without notice. If we shut Pull.fm down, we will make a - reasonable effort to give notice and time to export your data, but we do not - promise it. + [`../SECURITY.md`](../SECURITY.md), measured in business days. +- We may change, suspend or discontinue any part of Pull.fm, including all of it, + at any time and without notice. If we discontinue Pull.fm we will make + reasonable efforts to give notice and an opportunity to export your data, but + we do not undertake to do so. -Because Pull.fm is free, you are not paying for availability and we are not -selling it. +Pull.fm is free. You are not paying for availability and we are not selling it. --- @@ -553,242 +333,102 @@ selling it. To the maximum extent permitted by law: - We are **not liable** for any indirect, incidental, special, consequential, - exemplary, or punitive damages, or for lost profits, lost data, or loss of + exemplary or punitive damages, or for lost profits, lost data or loss of goodwill, arising out of or relating to Pull.fm, on any theory of liability, - even if we have been advised of the possibility. + even if advised of the possibility. - Our **total aggregate liability** to you for all claims relating to Pull.fm is limited to **one hundred United States dollars (USD 100)**. -- The same limitations apply, to the same extent, for the benefit of the - **SeatGeek Entities** and every other upstream provider named in section 7. +- The same limitations apply, to the same extent, for the benefit of the SeatGeek + Entities and every other upstream provider named in section 7. - **THE MAXIMUM AGGREGATE LIABILITY OF THE SEATGEEK ENTITIES FOR ALL DAMAGES, LOSSES, AND CAUSES OF ACTION IN CONNECTION WITH SEATGEEK MATERIALS, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE), OR OTHERWISE, IS FIFTY UNITED STATES - DOLLARS (USD 50.00).** This is lower than our own cap, deliberately, and it is - the figure SeatGeek's own API Terms of Use set for themselves. + DOLLARS (USD 50.00).** -Some jurisdictions do not allow these limitations, so parts of this section may +Some jurisdictions do not permit these limitations, so parts of this section may not apply to you. Nothing in these Terms limits liability that cannot lawfully be -limited, including for fraud, or for death or personal injury caused by +limited, including for fraud or for death or personal injury caused by negligence. - - --- ## 14. Indemnity You will indemnify and hold harmless 312.dev LLC, its members and personnel, and -**the SeatGeek Entities**, against any claim, demand, loss, or expense (including -reasonable legal fees) arising out of your breach of these Terms, your misuse of -Pull.fm, or your use of data obtained through Pull.fm in a way sections 3, 7, or -8 prohibit. +the SeatGeek Entities, against any claim, demand, loss or expense, including +reasonable legal fees, arising out of your breach of these Terms, your misuse of +Pull.fm, or your use of data obtained through Pull.fm in a manner prohibited by +sections 3, 7 or 8. --- ## 15. Termination -You may stop using Pull.fm at any time and delete your account with -`DELETE /v1/me`, or from the account screen in a client. Deletion is -irreversible; see [`privacy-policy.md`](privacy-policy.md) section 7 for exactly -what it removes and what remains in backups. +You may stop using Pull.fm at any time and delete your account from the account +screen in a Pull.fm client, or by the equivalent request to our API. Deletion is +irreversible; +[`privacy-policy.md`](privacy-policy.md) section 7 states what it removes and +what remains in backups. -We may suspend or terminate your access at any time for breach of these Terms, -to protect the service or its users, or if an upstream provider requires it. +We may suspend or terminate your access at any time for breach of these Terms, to +protect the service or its users, or if an upstream provider requires it. -Sections 6 (your content licence, as to content already lawfully processed), 7, -8, 9, 11, 13, 14, and 16 survive termination. +Sections 6 (as to content already lawfully processed), 7, 8, 9, 11, 13, 14 and 16 +survive termination. --- ## 16. Governing law and disputes -> ### This section is no longer void, and one thing about it is deliberate -> -> Both placeholders are filled: **312.dev LLC is organised in Illinois**, so -> Illinois law governs and the Illinois courts are the forum. The warranty -> disclaimer (11), the liability cap (13) and the indemnity (14) now rest on a -> choice of law that chose something. -> -> **No county is named, on purpose.** A venue clause is enforceable when it fixes -> a state and a court system, and naming a county would have meant guessing the -> LLC's principal place of business, which nobody has confirmed. Note also that -> Illinois has **two** federal judicial districts, Northern and Southern, so -> "the Northern District" would have been a guess as well. If the operator wants -> a specific county and district, this clause can be narrowed later without -> reopening anything. - These Terms are governed by the laws of the **State of Illinois**, United States, without regard to its conflict-of-laws rules. The exclusive venue for any dispute -arising out of or relating to these Terms or to Pull.fm is the **state and -federal courts located in the State of Illinois**, and you and we each consent to -personal jurisdiction and venue in those courts and waive any objection to them -on grounds of inconvenient forum. +arising out of or relating to these Terms or to Pull.fm is the state and federal +courts located in the State of Illinois, and each party consents to personal +jurisdiction and venue in those courts and waives any objection to them on +grounds of inconvenient forum. + +No county is specified. A venue clause is effective where it fixes a state and a +court system, and Illinois contains two federal judicial districts. If you are a consumer resident in a jurisdiction whose law gives you a non-waivable right to the protection of your local law or to bring proceedings in your local courts, nothing in this section removes that right. -**Decided 2026-07-29 by the controller: no arbitration clause and no -class-action waiver.** This section stays as drafted. The reasoning is recorded -here because the decision should be re-examined on its premises rather than -re-argued from scratch: - -- **The forum costs more than the liability it would protect.** The cap in - section 13 is USD 100. Business-side arbitration fees exceed that by roughly - 12x to 35x at every scale, and there is no number of claimants at which - arbitrating is cheaper than simply paying each claimant the cap. AAA also - charges an annual consumer-clause registry fee in perpetuity whether or not - anyone ever files, which for a service with no revenue is a subscription - bought to obtain a worse outcome. -- **The Illinois-specific reason to want one does not apply.** BIPA - (740 ILCS 14) carries a private right of action, and a class-action waiver is - the standard defence against it. But BIPA's section 10 definition is a closed - list of biometric identifiers that expressly excludes photographs, and every - section 15 duty is conditioned on collecting or possessing one. This service - collects none, verified against the schema and the auth flow, so there is no - duty and nothing to waive. -- **The external pressure is absent, and this is now checked rather than - assumed.** SeatGeek clause 4.3 requires terms "at least as protective" as - theirs, so if their API terms compelled arbitration this section might have had - to match. They do not. Their clause 12.2 selects New York law and the exclusive - venue of the state and federal courts of New York County, and contains **no - arbitration clause and no class-action waiver**. So the requirement we are - matching does not include one, and our omission cannot fall short of it. - - **An earlier version of this bullet said their terms "could not be read", and - that was true when it was written.** Every automated fetch of `seatgeek.com` - returns 403, including its public press page, so this was recorded as an input - that was absent rather than satisfied. It turned out to be blanket bot-blocking - rather than a login wall: the operator opened the page in an ordinary browser - on 2026-07-29 and transcribed the clauses, which is why the verbatim text now - sits in - [`../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md`](../packages/upstream/vendor-specs/seatgeek-api-terms-2025-03-17.md). - Leaving the old sentence would have had this document assert its own source was - unreadable in the same revision that quotes it. - -**RE-OPEN THIS IF EITHER PREMISE CHANGES**, and both are foreseeable. If a -feature ever touches voice, face or fingerprint data, BIPA attaches and this -calculation inverts. If the service ever charges money, the cap and the -cost-benefit both move. Note that adding a clause later binds only users who -accept the amended terms, so this is cheap now and expensive to reverse. - -If one is ever added: name **JAMS** and expressly invoke its Mass Arbitration -Procedures, which charge a flat filing fee regardless of case count, rather than -AAA, which charges per case and scales linearly against the defendant. Include an -express bar on class arbitration and state that the waiver is non-severable, -because _Kinkel_ severed a waiver and enforced the clause without it, sending the -defendant into class arbitration. +**These Terms contain no arbitration clause and no class-action waiver.** --- ## 17. Changes to these Terms We may update these Terms. When we do, we will change the "Last updated" date and -publish the new version at the same stable URL, and the change history is visible -in this repository's git log, which is public. +publish the new version at the same stable URL. The change history is visible in +this repository's public version control. -**A material change requires you to accept it. We do not treat continuing to use +**A material change requires your acceptance. We do not treat continued use of Pull.fm as acceptance of one.** When we publish a material revision, Pull.fm asks -you to read it and accept it, and until you do: +you to read and accept it, and until you do: - **you keep read access.** Your account is not suspended and nothing is deleted. - A revision is our act, not yours, and locking you out of your own data because - we rewrote a document would be a penalty for something you did not do. -- **changes you make are refused.** You may not add to your wishlist, mint a - token, or connect an account, because those are the things the Terms govern and - we do not have your agreement to the version that would govern them. -- **you may always leave.** Export and deletion keep working. They are never - conditioned on accepting anything, which is stated in - [`privacy-policy.md`](privacy-policy.md) and enforced in the code. - -A change that is **not** material does not ask anything of you. That distinction -is not ours to make loosely: every published revision is recorded with a version, -a digest of its exact text, and a flag saying whether it was material, in an -append-only table whose rows cannot be edited afterwards. Marking a material -change as cosmetic to avoid asking you is therefore a thing we would have to do on -the record. - -> **This clause used to read: "Continuing to use Pull.fm after a change takes -> effect means you accept it."** It was replaced for two reasons, and the second -> is the one that matters. -> -> First, that is the arrangement that failed in `Sgouros v. TransUnion Corp.`, 817 -> F.3d 1029 (7th Cir. 2016), which is Seventh Circuit law over Illinois and -> therefore over these Terms. A court declined to find a contract there even -> though the terms were displayed and the user completed a paid purchase, because -> the interface did not communicate that proceeding was assent. Continued use -> communicates less than that, not more. -> -> Second, **it described a weaker mechanism than the one Pull.fm actually -> implements**, which made the sentence untrue about our own system. The service -> does not infer your agreement from continued use; it stops accepting your -> changes and asks. So the old clause simultaneously relied on a theory a court -> rejected and understated the control that makes the theory unnecessary. Both -> halves are fixed by describing what the code does. + A revision is our act and not yours. +- **changes you make are refused.** You may not add to your wishlist, create a + token, or connect an account, because those are the matters these Terms govern + and we do not have your agreement to the version that would govern them. +- **you may always leave.** Export and deletion continue to operate and are never + conditioned on your accepting anything. + +A change that is not material asks nothing of you. Every published revision is +recorded with a version, a digest of its exact text, and a flag recording whether +it was material, in an append-only table whose rows cannot be altered afterwards. --- ## 18. Miscellaneous - **Entire agreement.** These Terms and the [Privacy Policy](privacy-policy.md) - are the entire agreement between you and us about Pull.fm. -- **Severability.** If a provision is unenforceable, the rest stands. -- **No waiver.** Not enforcing a provision once does not waive it. + are the entire agreement between you and us concerning Pull.fm. +- **Severability.** If a provision is unenforceable, the remainder stands. +- **No waiver.** A failure to enforce a provision on one occasion does not waive + it. - **Assignment.** You may not assign these Terms. We may assign them to a successor of the business. @@ -796,12 +436,9 @@ the record. ## 19. Contact -**312.dev LLC**, Illinois, United States +312.dev LLC, Illinois, United States Email: `ope@312.dev` -Security reports: see [`../SECURITY.md`](../SECURITY.md) +Security reports: [`../SECURITY.md`](../SECURITY.md) -**No postal address is published, and that is a decision rather than an -oversight.** [`privacy-policy.md`](privacy-policy.md) section 2 records what was -checked before making it, and names the two events that reverse it: entering an -app store, and sending a first marketing email, which is when CAN-SPAM's physical -address requirement attaches. +No postal address is published; [`privacy-policy.md`](privacy-policy.md) section +2 records the basis for that decision.