diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..59f9180 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,407 @@ +# Contributing to rfc-app + +`rfc-app` is the framework that hosts RFC-shaped collections of +documents — one repo per RFC, a meta repo per collection, a web app +that turns the Git substrate into a writeable surface. The Open +Human Model (OHM) deployment at `ohm.wiggleverse.org` is one +instance. The framework is intended to host more. + +This document explains how to propose a change to the framework +itself — a new endpoint, a schema migration, a UI affordance, a +spec clarification. For changes to *content* hosted by a specific +deployment (the OHM RFCs, the OHM roadmap), see that deployment's +own contribution guide (e.g. [`ohm-rfc/CONTRIBUTING.md`](https://git.wiggleverse.org/wiggleverse/ohm-rfc/src/branch/main/CONTRIBUTING.md)). + +--- + +## How the project actually evolves + +rfc-app is built in the open in the literal sense: **every build +session produces a full transcript** at +[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history) +on `git.wiggleverse.org`. The transcripts are the authoritative +record of how the framework got from one release to the next — the +decisions, the friction, the dead ends, the reasoning. They are not +curated retrospectives; wrong turns stay in. + +If you are proposing a change to rfc-app, **read at least the most +recent session transcript before opening a PR.** The transcripts +show what shape a feature lands in, where the spec gets touched, +what the operator pushes back on, and how the release rides into +deployment. A PR that matches that texture is much more likely to +land cleanly than one shaped by the README alone. + +Worked examples to start with: + +- **Session E** ([transcript](https://git.wiggleverse.org/wiggleverse/ohm-session-history)) — + a clean small release. Read this for the simplest possible release + shape: one feature, one version bump, one upgrade-steps block, no + surprises. +- **Session I** — recovery from a deploy fault. Read this for how + the project handles things going wrong mid-deploy, and for the + honest no-curation discipline. +- **Session K** — a multi-feature wave with one item paused on an + operator-provided secret. Read this for the subagent dispatch + pattern (the model the project uses to ship multiple features in + parallel), and for the binding rule that the assistant **never** + asks the operator to paste secret bytes into the conversation. +- **Session L** — squash-merge integration across three parallel + features (v0.15.0 / v0.16.0 / v0.17.0), with `#21 Part C` + identity-lifecycle Amplitude wiring folded inline across all + three releases. Read this for how cross-cutting concerns (analytics, + observability) get layered into already-in-flight features + without scope-creeping any single release. + +The repository where transcripts live — +[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history) — +is the canonical history. The `git log` of `rfc-app` is the artifact; +the transcripts are the story behind it. + +--- + +## How a contribution flows + +The framework runs on a **subagents push feature branches; operator +tags and deploys** model. Contributors — whether human or AI agents +running in a Claude Code subsession — open feature branches and +submit PRs. The operator (the person running the deployment) is the +one who merges, tags, bumps `VERSION`, runs `flotilla deploy` (or +the equivalent for non-OHM deployments), and moves the deployment's +`.rfc-app-version` pin. The driver session transcripts inherit +this shape; contributors inherit it from them. + +Concretely: + +1. Read the most recent session transcript. Understand what just + shipped and what is in flight. +2. Open an Issue first if your change is exploratory, structural, + or might overlap with in-flight work. The operator will name + any collision. +3. Branch from `main`. Name the branch + `feature/` for additive work, `fix/` for bug fixes, `docs/` for + documentation-only work. The driver sessions use + `feature/v-` (e.g. + `feature/v0.16.0-owner-invite`) — that shape is welcome but not + required for outside contributors, since contributors do not + pick the target version. +4. **Do not bump `VERSION` or `frontend/package.json#version` in + your PR.** The operator picks the target version at integration + time; bumping ahead causes cherry-pick conflicts. The same + applies to the `CHANGELOG.md` entry header — see below. +5. **Do not tag releases, do not run any deploy gesture, do not + touch any deployment's `.rfc-app-version` pin.** The operator + alone owns those gestures. (For OHM specifically: "I'm the only + one that gets to yolo." See the boundary section in + `ohm-rfc/CONTRIBUTING.md`.) +6. Push your branch and open a PR. Describe what you're proposing + and why, in language the operator can paste into the eventual + release commit. If the change touches `SPEC.md`, name which + section(s) and the contract change. + +--- + +## CHANGELOG convention: strict descending + +`CHANGELOG.md` is ordered **newest-on-top**. The header line for +the in-progress version goes at the top of the file; older +releases descend below it. This is the binding convention; the +operator hand-resolves the conflict when two parallel feature +branches both insert at the top of the file (the squash-merge +integration that ships parallel-feature waves keeps the strict- +descending shape — see Session K for the cherry-pick mechanics and +Session L for the hand-resolved-with-a-small-script variant). + +A new entry has this shape (read the existing 0.15.0 / 0.16.0 / +0.17.0 entries for worked examples): + +```markdown +## 0.X.Y — YYYY-MM-DD + +**Minor — schema migration auto-applied; no operator action.** This +release ships . + +### Added +- **** — what it does, where it lives, + why it exists. Include file paths inline so a reader can click through. +### Changed +- **** — what changed and how a deployment notices. +### Migration +- **`.sql`** — auto-applied by `db.run_migrations()` on + backend start. +### Upgrade steps (from 0.(X-1).Y) +- You **MUST** … (per RFC 2119; see SPEC.md §20.4). +- You **MUST NOT** … +- You **SHOULD** … +- You **MAY** … +``` + +The header version number is filled in by the operator at merge +time. Your PR's CHANGELOG diff can leave the version as +`0.X.Y — YYYY-MM-DD` (literal placeholder), or use a guessed value +the operator overwrites; either is fine. + +--- + +## `Upgrade steps:` blocks use RFC 2119 keywords + +If your change requires deployments to do anything when they +upgrade — set an env var, apply a migration, restart a process, +flip an overlay value, accept a behavioral change — your CHANGELOG +entry **must** include an `### Upgrade steps` block, and that +block **must** use the [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) +/ [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) keywords as +defined in `SPEC.md` §20.4: + +- **MUST** / **SHALL** / **REQUIRED** — without this step the + deployment will not function correctly. Skipping is a regression + the framework does not handle. +- **MUST NOT** / **SHALL NOT** — previously valid, now no longer + supported. +- **SHOULD** / **RECOMMENDED** — the framework's tested path. A + deployment may deviate when it has a reason. +- **SHOULD NOT** / **NOT RECOMMENDED** — discouraged without being + forbidden. +- **MAY** / **OPTIONAL** — an affordance you can take or skip. + +Cross-version upgrades (jumping more than one minor) are computed by +the operator composing each intervening release's steps in order. +Each adjacent step must therefore be locally unambiguous — this is +the whole reason the keyword discipline is binding. Avoid words +like "should probably" or "might want to" inside an upgrade step; +either the framework needs the action or it doesn't. + +If your change touches the env contract, **also update** +`backend/.env.example` and/or `frontend/.env.example` in the same +PR so the contract and the documentation land together (§20.4). + +--- + +## SPEC.md and §19.2 candidates + +`SPEC.md` is the framework's binding spec. It is honest about open +questions — large sections of it carry "§19.2 candidates," which +are decisions the project has deliberately deferred rather than +guessed at. + +The discipline: **architectural or process deferrals get noted as +§19.2 candidates rather than scope-creeping a release.** When you +notice that your change opens a question larger than the change +itself (a different DB shape, a new auth contract, a cross-cutting +UX rethink), the right move is usually to land the narrow change +and add a §19.2 candidate naming the larger question. The candidate +documents what was set aside and why, so a future session can pick +it up with context. + +Worked examples from recent sessions: + +- v0.11.0 (Session K) shipped device trust and surfaced three new + §19.2 candidates: cross-device session revocation, password- + equivalent change invalidating trust, device-trust window + tunables via env. None of those were in the v0.11.0 scope; they + were noted in SPEC.md §19.2 so a future session can address them + on their own terms. +- v0.15.0 (Session L) shipped the Amplitude wrapper and added + candidates around session-replay-specific consent category + + bundle-size measurement, both deferred to the future Part-A audit. + +When you spot a deferred decision in your PR's territory, name it +in your PR description and add it to `SPEC.md` §19.2 in the same +diff. Do not silently expand scope to settle it. + +--- + +## Test-coverage expectations + +The backend has the load-bearing test suite at +`backend/tests/`. Tests are organized as `*_vertical.py` files, +each covering one feature end-to-end through the FastAPI app +(provisioning fixtures, hitting the HTTP surface, asserting on the +database state). At time of writing, the suite is ~250 tests across +~25 files. Examples: + +- `test_admin_create_user_invite_vertical.py` — v0.17.0's + admin-create user + invite + claim flow, 15 tests covering happy + path + every refusal shape + the audit-trail row. +- `test_rfc_invitations_vertical.py` — v0.16.0's per-RFC invite + + accept flow, 18 tests. +- `test_device_trust_vertical.py` — v0.11.0's 30-day device trust, + 14 tests including cookie shape, hash-vs-raw-token discipline, + expired / revoked / forged / cross-user invariants. + +Expected coverage for a new feature: + +- **Backend feature** — one new `test__vertical.py` file + that covers the happy path, every documented refusal/error code, + and any cross-surface effect (rows the feature writes to existing + tables, fields it adds to existing endpoints). Reuse fixtures + from neighboring test files (e.g. `test_propose_vertical.py`'s + `FakeGitea` is widely reused). +- **Migration** — verify migrations are reachable from `backend/.venv` + before pushing: `cd backend && PYTHONPATH=. .venv/bin/pytest -q` + exercises `db.run_migrations()` through the fixture setup. +- **Frontend feature** — there is currently no frontend test + runner. The discipline is: keep the change ships-clean + (`cd frontend && npm run build` succeeds), and the backend + vertical test exercises the HTTP contract the frontend + consumes, which is the meaningful behavioral guarantee. + Frontend changes that ride along with a backend feature land + with the backend test as the regression boundary. +- **Bug fix** — add a regression test in the same vertical file + that proves the original failure mode and verifies the fix. + +Run the backend suite before pushing. From `backend/`: + +```bash +PYTHONPATH=. .venv/bin/pytest -q +``` + +(The `PYTHONPATH=.` is a known ergonomic gap — see SPEC.md §19.2 +candidate; the suite does not pick up `app/` without it.) + +If your PR doesn't include tests, the operator will ask for them +before merge unless the change is genuinely test-irrelevant +(documentation, comments, dev-only tooling). + +--- + +## Analytics instrumentation checklist + +> *(This section codifies `ohm-rfc/ROADMAP.md` #21 Part B's +> CONTRIBUTING checklist. It is discipline, not a gate — but the +> operator will push back on PRs that skip it.)* + +If your PR adds or changes a user-facing feature, walk this +checklist before opening the PR. The instrumentation conventions +themselves are specified in `SPEC.md` §21 (Analytics instrumentation +and identity); this section is the procedural reminder. + +1. **What named event(s) does this feature need?** + Open `frontend/src/lib/analytics.js` and look at the `EVENTS` + constant. Does an existing event cover your feature? If not, is + the new event in the spec's "Subject Verb" Title Case form + (`Comment Posted`, `Invitation Sent`)? Are the prop families + consistent with SPEC.md §21's required-prop catalog (opaque + ids only, no PII, enums lowercased like `'otc'` not `'OTC'`)? + +2. **Do interactive elements have stable text / ARIA labels / + `data-amp-track-*` so autocapture is meaningful?** + The frontend ships `autocapture: true`, which instruments + every click and form interaction. The *value* of those events + depends on the DOM the SDK sees: a ` + ``` + + This makes per-RFC click counts aggregate to the RFC rather + than to a generic label, and lets the dashboard answer "which + RFCs got the most engagement" rather than "how many buttons + were clicked." +- **`data-amp-track-suppress` for noise surfaces.** Crowded surfaces + (the admin user-listing post-v0.9.0, the RFC discussion panel + during heavy review) **MAY** apply + `data-amp-track-suppress` (or its current equivalent in the + SDK version in use) to elements whose clicks would flood the + dashboard without informing anything. Suppression is a + deliberate decision; document it inline. + +### 21.4 Session-replay masking conventions + +The wrapper initializes Amplitude with `sessionReplay.sampleRate: 1` +(100% of consented sessions are recorded for full-DOM playback — +vendor-recommended default for new Amplitude deployments). Replay +has a meaningfully larger privacy footprint than event counters, +and the masking discipline is binding. + +- **Credentials MUST be masked.** The OTC code input, passcode + input, any password-type field, the Turnstile widget internals, + the magic-link-claim token if it survives in the URL bar + (browser history, screenshot windows) — these **MUST** be masked + with Amplitude's masking convention (the `.amp-mask` class or the + `data-amp-mask` attribute, whichever the wrapper's SDK version + uses; the wrapper's bootstrap comment names the current + convention). Confirm each masking attribute survives the + wrapper init by inspecting a recorded session before each + release that touches an auth input. +- **PII SHOULD be masked or carefully un-masked.** Email-entry + fields, real-name capture fields (the v0.8.0 first/last/why + panel), free-text RFC body drafts, comment-compose text — + each is arguably PII or near-PII. The per-field decision is + the release's responsibility; document the choice in `SPEC.md` + §21 (this section) and in the release CHANGELOG so future + deployments inherit the call rather than re-deciding. +- **Privacy-policy alignment.** The recorded data **MUST** match + what the deployment's privacy / cookies policy claims. If + reality is broader than the document promises, update the policy + text in the same release. +- **Selective redaction.** Amplitude supports field-level mask + classes that hide value while preserving DOM shape (so the + session is replayable but the value is not). Prefer this over + whole-form masking when only a subset is sensitive. + +### 21.5 Consent-gate contract + +The wrapper is bound to the v0.13.0 cookie/privacy consent surface +(`frontend/src/lib/consent.js`, §14.5). The binding is **load- +bearing**: the SDK and the session-replay recorder **MUST NOT** +load before consent is granted, and any consent revocation **MUST** +take effect within one tick of the consent flip. + +The wrapper's bootstrap implements this contract; releases that +touch the analytics surface **MUST** preserve it. + +- **Pre-consent: no init, no network, no recording.** If + `consent.analytics === true` is not currently true (either + because the user denied, or because the banner is up and no + decision has been recorded), the wrapper **MUST NOT** import + the Amplitude SDK, **MUST NOT** open any network request to + Amplitude, and **MUST NOT** start any session-replay recording. + The consent-gated lazy `import('@amplitude/unified')` is the + binding implementation; preserve it. +- **Denied → granted: init at the consent moment.** When consent + flips from denied/undecided to granted, the wrapper **MUST** + initialize the SDK at that moment (a new `initAll(KEY, …)` call + through the lazy-import path). Track and identify calls made + before init resolves **MUST** be queued and drained on init, + so the first signed-in user's first event is not dropped on + the cold-load race. +- **Granted → denied: setOptOut(true) within one tick.** When + consent flips from granted to denied mid-session, the wrapper + **MUST** call `amplitude.setOptOut(true)` so subsequent events + are dropped client-side and session replay stops recording. + The wrapper cannot unload the script tag (the SDK is already + in memory), but the SDK's contract for "drop subsequent events" + is `setOptOut(true)`. This **MUST** happen within one tick of + the consent flip (i.e. synchronously inside the + `onConsentChange` handler). +- **No silent re-grant.** A granted → denied → granted sequence + **MUST** call `setOptOut(false)` (re-enabling the SDK that was + paused) rather than firing a second `initAll` (which would + double-init). The wrapper's bootstrap implements this; releases + that touch the consent integration **MUST** preserve the + distinction. +- **Build-time vs. runtime.** The API key is read from + `import.meta.env.VITE_AMPLITUDE_API_KEY` at build time. When + the env var is unset, the wrapper **MUST** log one console + warning and no-op (every public function becomes a deterministic + no-op) so dev environments without an Amplitude account keep + working. The deploy gesture binds the key via the deployment's + overlay verb (for OHM-shape deployments, `flotilla overlay set`); + see §21.8 for the secret-vs-public discussion. + +### 21.6 Identity lifecycle (per #21 Part C) + +Amplitude's identity model has a specific pattern that the +framework follows verbatim. Every release that touches an +identity-meaningful surface **MUST** observe this pattern. The +pattern shipped inline across v0.15.0 / v0.16.0 / v0.17.0 +(Session L's wave); this section codifies the contract so future +releases inherit it. + +**On sign-in success** (`App.jsx`'s `me.user` resolution): + +- The wrapper's `identify({ user_id, properties })` call **MUST** + carry both the OHM user_id (`amplitude.setUserId()` + internally) AND the user's durable property bag. `setUserId` + alone is **NOT** sufficient — without properties, the Amplitude + user record carries only the id, and cohort analysis loses the + shape (role distribution, sign-in-method distribution, etc.) + the dashboard depends on. +- Properties **MUST** be a bag of opaque ids, enums, booleans, + and timestamps — no PII (no email, no display name, no IP). +- Properties **MUST** be classified `set` vs `setOnce` deliberately + (see §21.6.1 below). +- The same `identify` call **MUST** be re-issued on every sign-in + (idempotent at Amplitude's side; cheap; corrects any drift in + the mutable property half). + +**On user-state change mid-session** (role grant/revoke, trust- +device add, passcode set, beta-permission flip): + +- The wrapper's `setUserProperties(properties)` call **MUST** fire + so the Amplitude record stays current. Mid-session state changes + **MUST NOT** wait for the next sign-in to surface — the dashboard + cohort an admin uses to grant permission is the same dashboard + that next sees the granted user's behavior; staleness here breaks + the cohort feedback loop. + +**On sign-out**: + +- The wrapper's `anonymize()` call (internally `amplitude.reset()`) + **MUST** fire. `reset` clears the device-id linking AND **MUST** + also clear the property cache so the next anonymous session is a + fresh slate (the wrapper's `anonymize()` does both — releases + that touch the wrapper **MUST** preserve this). +- The `User Signed Out` `track()` call **MUST** fire *before* + `anonymize()`, so the sign-out event is correctly attributed to + the signing-out user rather than to the post-reset anonymous + device. + +**On invite-claim** (the v0.16.0 per-RFC invite + v0.17.0 admin- +create invite paths): + +- The wrapper's `identify({ user_id, properties })` call **MUST** + fire BEFORE the first `track()` event on the claim surface, so + the Amplitude user record is created with the OHM user_id from + the first event. **MUST NOT** fire `track()` first and `identify` + later — that creates an anonymous device record that + retroactively links, and the cohort attribution for + invite-driven onboarding loses precision. +- The invite-context properties (`claim_method`, + `invited_by_admin_id`, `invited_at`, `initial_role`) are + `setOnce` (immutable user-history markers) — see §21.6.1. + +**Inviter-side identification on invite-send events**: + +- The inviter's `track('Invitation Sent', …)` and + `track('User Invited', …)` events fire from the inviter's + signed-in session, so the `user_id` attribution is already + correct (it's the inviter's id). The event body carries + `target_user_id` (#16 — admin-create, where the future user is + provisioned at create-time) or a hashed `target_email` + fingerprint (#12 — per-RFC invite, where the invitee is not + yet a user) so the invite + claim pair can be correlated later + in the dashboard. + +#### 21.6.1 `set` vs `setOnce` taxonomy + +Amplitude distinguishes two property-write semantics: + +- **`set(k, v)`** — overwrites the property on every call. The + user record reflects the most recent value. +- **`setOnce(k, v)`** — writes only if the property is not + already present. Subsequent calls are no-ops. The user record + reflects the first value ever written. + +The wrapper's `applyProperties` function accepts both: a bare +value uses `.set()`; a sentinel-wrapped value +`['__setOnce__', value]` uses `.setOnce()`. Releases that add new +user properties **MUST** classify each one explicitly, by the +following rule: + +- A property whose value is **expected to change over the user's + lifetime** is `set`. Examples: `role` (can flip from + `contributor` to `owner`), `permission_state` (pending → granted), + `passcode_set` (false → true), `device_trusted` (changes per + active device). On each sign-in, the latest value is written; + the dashboard always sees current state. +- A property that is an **immutable historical marker** is + `setOnce`. Examples: `first_sign_in_at` (the timestamp of the + user's first observed sign-in — never re-write), `account_ + created_at` (the timestamp of provisioning), + `invited_by_admin_id` (the admin who provisioned this user via + the v0.17.0 path — preserved even if the user is later + re-invited or has their role changed), `invited_at` (the + timestamp at which the invite was sent — distinct from + `claim_method` which is also setOnce because once claim_method + is `'admin-invite'`, that's the path this user took). + +The classification is part of the release's contract — flipping a +property from `set` to `setOnce` (or vice versa) mid-life corrupts +the user record and **MUST** be avoided. If a property's +semantics genuinely change, retire the old key and introduce a new +one (same deprecation discipline as event renames in §21.1). + +### 21.7 Cohort-shape implications (informative) + +The conventions above are designed so that the Amplitude dashboard +can answer cohort questions the operator actually asks: + +- *"How many users signed in via the admin-invite path in week N + vs. organic OTC?"* — uses `claim_method` (setOnce) on the user + record + `User Signed In` events with `method`. +- *"Of admin-invited users, what fraction set a passcode within + their first session?"* — uses `claim_method` + `passcode_set` + on the user record + `Page Viewed` events to define "session." +- *"Which RFCs have the most owner-invited contributors?"* — uses + per-RFC `Invitation Sent` / `Invitation Accepted` correlated + via `rfc_slug` + `role_in_rfc`. +- *"What's the gap between invite-send and invite-claim, broken + out by inviter?"* — uses `Invitation Sent` (inviter's session, + inviter `user_id`) + `Invitation Accepted` (invitee's session, + invitee `user_id` after the BEFORE-track identify) joined on + `rfc_slug` + inviter (the inviter's id is the same id on both + events because both invite and claim sides observe it). + +The Part-A audit (per `ohm-rfc/ROADMAP.md` #21) confirms these +shapes against real data once a week of beta traffic is in. The +audit is a point-in-time pass; this chapter is the standing +discipline that keeps future work in shape. + +### 21.8 Secret vs. public — overlay binding + +The Amplitude browser API key (`VITE_AMPLITUDE_API_KEY`) is +**bundle-embedded by design**: it appears as a literal string in +the deployment's JavaScript bundle, visible to anyone with browser +dev tools. The vendor's installation prompt embeds it inline. This +puts it in the same category as Cloudflare Turnstile's site key +(`VITE_TURNSTILE_SITE_KEY`, v0.12.0) — public, not secret. + +Deployments **MUST** bind such keys via their overlay verb (for +OHM-shape deployments, `flotilla overlay set`), not via the secret +binding. The matching secret half (the Cloudflare Turnstile +**secret** key, `CLOUDFLARE_TURNSTILE_SECRET`, used server-side +for siteverify) is a true secret bound via `flotilla secret set`. +This per-key distinction is the deployment's responsibility; the +framework's `*.env.example` files name the binding for each. + +The binding rule baked in mid-Session-K is: **the operator's +secret bytes never enter the conversation with an assistant**, +even as one offered option. The conversation-layer corollary of +the build-pipeline §3-invariant-1 rule from +`ohm-rfc-app-flotilla/SPEC.md` is that sessions publish in full, +and a secret in a transcript is a leaked secret. The canonical +secret-set gesture for OHM is `pbpaste | flotilla secret set + ` (the value goes clipboard → stdin → +Secret Manager without ever appearing in shell history or the +model context). Non-OHM deployments inherit the same discipline +through their own deploy tooling. + +### 21.9 §19.2 candidates surfaced by this chapter + +- **Session-replay-specific consent category.** v0.13.0's cookie + banner has a single `analytics` toggle that gates both event + counters and full-DOM session replay. Recording has a larger + privacy footprint than counters; a separate consent category + for session replay is the cleaner shape. Captured here and in + `ohm-rfc/ROADMAP.md` #21 Part A. +- **Bundle-size budget for the analytics wrapper.** The + `@amplitude/unified` package adds ~150 KB gzipped (the session- + replay recorder is the bulk). The consent-gated lazy import + keeps the cost off the initial bundle for users who haven't + opted in; the post-consent init path has not been measured + for jank. Captured in #21 Part A. +- **Property-shape lint.** The conventions in §21.1 / §21.2 are + enforced today by review discipline. A small lint (CI grep + against `track(` / `identify(` callsites with a property-key + allowlist + a PII-name denylist) is a future affordance that + catches drift mechanically. +- **Hashed `target_email` derivation.** §21.2 names SHA-256 of + the normalized lower-cased email as the hashing function. The + framework does not currently expose a helper for this — a + small `frontend/src/lib/hash.js` or `backend/app/hash.py` that + centralizes the normalization + hash would make the contract + enforceable. Captured here. + +### 21.10 Open question + +The wrapper currently uses the Amplitude SDK's autocapture + +session-replay defaults. The v0.13.0 consent surface has a single +toggle for "analytics." Splitting the consent into "analytics" +vs "session replay" is a §19.2 candidate (above) but settling it +also requires a privacy-policy update and a re-prompt of +existing consenters. The cleanest moment to do this is the next +material privacy-policy revision; the conventions in §21.4 hold +in the interim. +