Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 17 additions & 9 deletions apps/bff/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
20 changes: 20 additions & 0 deletions apps/bff/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,6 +19,22 @@ async function main(): Promise<void> {
// 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.
Expand Down
61 changes: 52 additions & 9 deletions apps/bff/src/lib/legal-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
}),
];

Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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<Record<string, string>> | null {
): Readonly<Record<string, HighlightSource>> | null {
const line = /^\*\*Highlights checked against:\*\*\s*(.+)$/m.exec(raw);
if (line === null) return null;
const out: Record<string, string> = {};
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<string, HighlightSource> = {};
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<Record<string, number>>;

Expand Down
Loading
Loading