docs: CONTRIBUTING.md + SPEC.md §21 analytics chapter
Lands roadmap item #19 (CONTRIBUTING + transcript-linked onboarding) and #21 Part B (standing analytics instrumentation discipline) as a single docs commit. No code change, no version bump — these ride a future release or land as a no-bump docs commit at the operator's discretion. CONTRIBUTING.md (new): - Project-evolution framing pointing at wiggleverse/ohm-session-history as the authoritative how-we-got-here record. - Worked-example links into Sessions E (clean small release), I (recovery from deploy fault), K (multi-feature wave with operator-secret pause), L (squash-merge across three parallel features + #21 Part C identity-lifecycle wiring). - PR-shape contract: subagents push feature branches; operator tags + deploys (the pattern driver sessions use). - CHANGELOG strict-descending convention. - RFC 2119 keyword discipline for Upgrade-steps blocks (per §20.4). - §19.2 candidate discipline — architectural deferrals get noted rather than scope-creeping a release. - Test-coverage expectations characterized from the actual ~250-test backend pytest surface (no frontend test runner today; the discipline is build-clean + backend HTTP contract coverage). - Operator-only gestures explicitly enumerated (no tagging, no deploying, no pin moves, no secret bytes in conversation). - Analytics instrumentation checklist (#21 Part B's CONTRIBUTING artifact): named events, autocapture-friendly DOM, replay masking, PR description discipline. SPEC.md §21 (new chapter — Analytics instrumentation and identity): - Placed AFTER §20 versioning rather than between §15 and §16, to avoid renumbering §19.2 / §19.3 — those numbers are load-bearing project nouns referenced from CLAUDE.md, transcripts, and prior commits. §15 carries a new forward-pointer naming §21 as the peer chapter. - §21.1 event-taxonomy conventions (Title Case "Subject Verb", snake_case props, no PII). - §21.2 required prop families per event kind, including the hashed-target_email convention for invite-side events that target a not-yet-user (#12). - §21.3 autocapture-friendly DOM patterns (stable text, aria-label on icon-only buttons, data-amp-track-* on repeated rows, data-amp-track-suppress for noise surfaces). - §21.4 session-replay masking — credentials MUST be masked, PII SHOULD be masked, privacy policy MUST match reality. - §21.5 consent-gate contract — pre-consent no init / no network / no recording; granted→denied → setOptOut(true) within one tick. - §21.6 identity lifecycle (per #21 Part C) — identify with user_id + properties on sign-in; setUserProperties on mid-session change; anonymize on sign-out (after the User Signed Out track); identify-BEFORE-track on invite-claim paths. §21.6.1 set vs setOnce taxonomy with explicit classification rule. - §21.7 cohort-shape implications (informative — what the Amplitude dashboard can actually answer with the conventions). - §21.8 secret-vs-public framing for the overlay binding — Amplitude browser key is public (bundle-embedded), Turnstile secret half is real-secret; canonical pbpaste-pipe gesture for the operator. - §21.9 new §19.2 candidates surfaced (session-replay-specific consent category, bundle-size budget measurement, property- shape CI lint, centralized email-hash helper). - §21.10 open question (consent-category split timing). Grounded in real code: the SPEC chapter cites frontend/src/lib/analytics.js v0.15.0 + the v0.16.0 (AcceptInvitation) + v0.17.0 (InviteClaim) inline #21 Part C wiring as worked examples. RFC 2119 keywords used precisely throughout.
This commit is contained in:
+407
@@ -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/<short-description>` for additive work, `fix/<short-
|
||||
description>` for bug fixes, `docs/<short-description>` for
|
||||
documentation-only work. The driver sessions use
|
||||
`feature/v<target-version>-<slug>` (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 <one or two sentences naming the feature and why>.
|
||||
|
||||
### Added
|
||||
- **<New module/endpoint/component>** — what it does, where it lives,
|
||||
why it exists. Include file paths inline so a reader can click through.
|
||||
### Changed
|
||||
- **<Existing surface>** — what changed and how a deployment notices.
|
||||
### Migration
|
||||
- **`<NNN_name>.sql`** — auto-applied by `db.run_migrations()` on
|
||||
backend start. <Describe the schema delta in one sentence.>
|
||||
### 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_<feature>_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 `<button>` with stable
|
||||
visible text or an `aria-label` shows up as a meaningful
|
||||
dashboard row; an icon-only `<button>` with no label shows up
|
||||
as garbage. New components that introduce interactive elements
|
||||
should either carry meaningful labels (visible text or ARIA) or
|
||||
carry a `data-amp-track-name="<Stable Name>"` attribute. For
|
||||
repeated rows (per-RFC lists, comment lists), use a stable
|
||||
`data-amp-track-*` identifier so per-row click counts aggregate
|
||||
to the row's identity rather than to a generic label.
|
||||
|
||||
3. **Does any new form field need replay masking?**
|
||||
Session replay records at `sampleRate: 1` (100% of consented
|
||||
sessions). New form inputs that capture passwords, OTC codes,
|
||||
tokens, magic-link URLs, or other secret/credential-equivalent
|
||||
material **MUST** be masked with Amplitude's masking conventions
|
||||
(the `.amp-mask` class or the `data-amp-mask` attribute,
|
||||
whichever the wrapper integration expects in this version).
|
||||
New inputs that capture arguably-PII (email, real name, free-
|
||||
text drafts) **SHOULD** also be masked; if a deliberate
|
||||
un-masking decision is taken, document it in the PR description
|
||||
and in `SPEC.md` §21.
|
||||
|
||||
4. **Does the PR description name the instrumentation decisions?**
|
||||
A one-sentence summary in the PR description — "fires
|
||||
`Comment Posted` with `{rfc_slug, comment_id}`; no new form
|
||||
fields, no new replay-masking concerns" — is enough. If the
|
||||
decision is "we chose not to instrument this," say that too;
|
||||
the absence of an event is itself a decision the operator
|
||||
wants visible. The relevant SPEC chapter (§21) is the binding
|
||||
reference for what shapes are correct.
|
||||
|
||||
If your feature touches an identity-meaningful surface (sign-in,
|
||||
sign-out, invite-claim, role change, account state change), also
|
||||
walk the **identity lifecycle** contract in SPEC.md §21.6: every
|
||||
new claim/sign-in path **MUST** call `identify({ user_id, properties })`
|
||||
BEFORE the first `track()` event on that surface, so the Amplitude
|
||||
user record is created with the OHM user_id from the very first
|
||||
event rather than as an anonymous device that retroactively links.
|
||||
v0.16.0's `AcceptInvitation.jsx` and v0.17.0's `InviteClaim.jsx`
|
||||
are the worked examples; mirror their shape.
|
||||
|
||||
---
|
||||
|
||||
## The operator-only gestures
|
||||
|
||||
Some gestures are operator-only. Contributors do not perform them;
|
||||
PRs that perform them get rejected on principle, not on merit:
|
||||
|
||||
- **Tagging a release** (`git tag v0.X.Y` + `git push --tags`).
|
||||
- **Pushing to `main`** after merge (the operator merges; the
|
||||
framework's `main` branch tracks releases the operator has
|
||||
shipped).
|
||||
- **Bumping `VERSION` and `frontend/package.json#version` to the
|
||||
shipped value.** The operator does this at integration time so
|
||||
the version line is consistent across the release commit.
|
||||
- **Running `flotilla deploy` or any equivalent deployment gesture**
|
||||
in any deployment of rfc-app. Contributors do not deploy.
|
||||
- **Moving a deployment's `.rfc-app-version` pin.** That pin lives
|
||||
in the deployment's content repo (e.g. `ohm-rfc/.rfc-app-version`)
|
||||
and is moved by the deployment's operator. Contributors to that
|
||||
deployment do not move it; contributors to the framework
|
||||
certainly do not.
|
||||
- **Setting secrets** (anywhere — Secret Manager, env files,
|
||||
`flotilla secret set`, vendor dashboards, anything). The
|
||||
binding rule baked in mid-Session-K is: **the assistant never
|
||||
asks the operator to paste secret bytes into a conversation,
|
||||
even as one offered option**. The corollary for contributors:
|
||||
do not include secret values in PR descriptions, commit
|
||||
messages, or issue comments. Reference secrets by their binding
|
||||
name (`SMTP_PASSWORD`, `AMPLITUDE_API_KEY`) and let the
|
||||
operator handle the bytes.
|
||||
|
||||
If your change requires a new secret or env var, document the
|
||||
requirement in the CHANGELOG `### Upgrade steps` block in the
|
||||
RFC 2119 form ("operators **MUST** set `<NEW_VAR>` ...") and
|
||||
update the `*.env.example` file. The operator will run the
|
||||
secret/overlay-set gesture themselves at deploy time.
|
||||
|
||||
---
|
||||
|
||||
## When in doubt
|
||||
|
||||
- **Open an Issue first.** Especially for any change that touches
|
||||
SPEC.md, the auth/permissions model (§6), the storage shape (§4
|
||||
/ §5), or the deploy contract (§20). The operator (or a future
|
||||
driver session) will name what they want before you write code.
|
||||
- **Read the most recent session transcript.** It will tell you
|
||||
what shipped last and what's in flight.
|
||||
- **Cite SPEC.md sections in your PR description.** "Touches §15.4
|
||||
(per-category email toggles) and adds §19.2 candidate around
|
||||
per-channel mute granularity" gives the operator a map of where
|
||||
to read.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
The framework is released under the MIT License (see
|
||||
[`LICENSE`](./LICENSE)). By contributing, you agree your work
|
||||
ships under those terms.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [`SPEC.md`](./SPEC.md) — the framework's binding spec. §19.2
|
||||
is the deferred-decisions queue; §20 is the versioning + deploy
|
||||
contract; §21 is the analytics instrumentation contract.
|
||||
- [`CHANGELOG.md`](./CHANGELOG.md) — release history in strict
|
||||
descending order. Read recent entries for the shape your PR's
|
||||
release-commit will take.
|
||||
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — what the framework is for.
|
||||
PRs whose shape conflicts with the philosophy get a longer
|
||||
conversation than PRs that fit.
|
||||
- [`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history)
|
||||
— the authoritative record of how the project has actually
|
||||
evolved, session by session.
|
||||
@@ -2284,6 +2284,13 @@ a given signal, the **storage shape** that makes triage tractable, and
|
||||
the **out-of-session channels** (email, digest) that let asynchrony
|
||||
actually work.
|
||||
|
||||
(The framework's separate **analytics + session-replay** surface —
|
||||
Amplitude wiring, event taxonomy, identity lifecycle, consent
|
||||
contract — is a peer cross-cutting concern specified in §21.
|
||||
Notifications cover in-product signal-of-others-acting-on-your-work;
|
||||
analytics covers observability of how the product is used. The two
|
||||
surfaces do not overlap.)
|
||||
|
||||
### 15.1 The signal-surface stack
|
||||
|
||||
Five surfaces, each with one narrow job:
|
||||
@@ -4300,3 +4307,465 @@ Downstream deployments, in exchange for the contract above, commit to:
|
||||
order;
|
||||
- supply every required env var the framework documents at the
|
||||
version they are running.
|
||||
|
||||
---
|
||||
|
||||
## 21. Analytics instrumentation and identity
|
||||
|
||||
The framework ships an Amplitude Analytics + Session Replay wrapper
|
||||
in v0.15.0 (`frontend/src/lib/analytics.js`), gated by the v0.13.0
|
||||
cookie/privacy consent surface (`frontend/src/lib/consent.js`,
|
||||
§14.5). This section codifies the conventions that keep the
|
||||
instrumentation **quality** healthy as features land — taxonomy
|
||||
shape, autocapture hygiene, replay masking, the consent contract,
|
||||
and the identity lifecycle. The conventions are framework-neutral:
|
||||
every deployment of rfc-app that turns on the wrapper inherits
|
||||
them.
|
||||
|
||||
This chapter is placed semantically after §15 (Notifications) and
|
||||
§16 (deliberately deferred) as a peer cross-cutting framework
|
||||
concern. It was added after §20 in the chapter sequence to avoid
|
||||
renumbering the deferred-decisions surface §19.2, which is a
|
||||
load-bearing project noun referenced across CLAUDE.md, transcripts,
|
||||
and prior commits.
|
||||
|
||||
### 21.1 Event-taxonomy conventions
|
||||
|
||||
Events live in the public `EVENTS` constant in
|
||||
`frontend/src/lib/analytics.js`. Callers **SHOULD** use one of the
|
||||
named constants rather than firing arbitrary event strings — that
|
||||
keeps the Amplitude dashboard coherent over time and makes the
|
||||
taxonomy reviewable as a single source of truth.
|
||||
|
||||
- **Name form: Title Case, "Subject Verb".** E.g.
|
||||
`Comment Posted`, `Invitation Sent`, `RFC Viewed`,
|
||||
`User Signed In`, `Admin Permission Decision`. Spaces between
|
||||
words, no punctuation, no leading verbs (use `RFC Proposed`,
|
||||
not `Propose RFC`). The strings match the Amplitude dashboard
|
||||
names exactly.
|
||||
- **Stability.** New events **SHOULD** land via a release, not
|
||||
ad-hoc — adding an entry to `EVENTS` is a CHANGELOG-worthy
|
||||
change because it widens the framework's observable surface
|
||||
(§20.3). Renaming an event after it has shipped breaks the
|
||||
dashboard's historical continuity; renames **SHOULD** be
|
||||
treated as a deprecation cycle (ship both, dashboard-migrate,
|
||||
drop the old one).
|
||||
- **Opaque ids only in prop values.** Properties **MUST NOT**
|
||||
carry PII — no email, no display name, no IP, no free-text
|
||||
field bodies (titles, comment text, RFC drafts). Properties
|
||||
**SHOULD** be limited to:
|
||||
- opaque ids: `rfc_slug`, `rfc_id`, `pr_number`,
|
||||
`target_user_id`, `invited_by_admin_id`, `thread_id`,
|
||||
`comment_id`;
|
||||
- enums (lowercased): `method: 'otc' | 'passcode' |
|
||||
'device-trust' | 'admin-invite' | 'rfc-invite'`;
|
||||
- booleans: `trust_device`, `needs_passcode`, `passcode_set`;
|
||||
- timestamps (ISO 8601);
|
||||
- small bounded integers: `custom_message_chars` (coarse-grained
|
||||
signal of admin effort, NOT the message text itself).
|
||||
- **Casing consistency.** Prop keys use `snake_case` (matches the
|
||||
backend's JSON shape). Enum values use lowercase with hyphens
|
||||
(`'rfc-invite'`, not `'rfcInvite'` or `'RFC_INVITE'`). Drift
|
||||
here ruins dashboard aggregation; the operator-side audit
|
||||
(§21.7 / `ohm-rfc/ROADMAP.md` #21 Part A) checks for it.
|
||||
|
||||
The starting taxonomy as of v0.17.0:
|
||||
|
||||
```
|
||||
PAGE_VIEWED: 'Page Viewed'
|
||||
RFC_VIEWED: 'RFC Viewed'
|
||||
USER_SIGNED_IN: 'User Signed In'
|
||||
USER_SIGNED_OUT: 'User Signed Out'
|
||||
RFC_PROPOSED: 'RFC Proposed'
|
||||
PR_OPENED: 'PR Opened'
|
||||
COMMENT_POSTED: 'Comment Posted'
|
||||
BETA_ACCESS_REQUESTED: 'Beta Access Requested'
|
||||
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision'
|
||||
INVITATION_SENT: 'Invitation Sent' # v0.16.0 / #12
|
||||
INVITATION_ACCEPTED: 'Invitation Accepted' # v0.16.0 / #12
|
||||
USER_INVITED: 'User Invited' # v0.17.0 / #16
|
||||
INVITE_CLAIMED: 'Invite Claimed' # v0.17.0 / #16
|
||||
```
|
||||
|
||||
### 21.2 Required prop families per event kind
|
||||
|
||||
Each event family carries a small required prop set. These are
|
||||
load-bearing for the dashboard's cohort analysis; releases that
|
||||
add a new event in an existing family **SHOULD** carry the
|
||||
family's required props.
|
||||
|
||||
- **Navigation events** (`Page Viewed`, `RFC Viewed`): carry
|
||||
`path` (string, pathname only — never the query string if it
|
||||
could carry a token) for `Page Viewed`; carry `rfc_slug` for
|
||||
`RFC Viewed`. `rfc_id` **MAY** be added when the cached row is
|
||||
in hand.
|
||||
- **Auth-state events** (`User Signed In`, `User Signed Out`):
|
||||
`User Signed In` carries `method` (one of `'otc'`,
|
||||
`'passcode'`, `'device-trust'`, `'admin-invite'`). `User
|
||||
Signed Out` carries no props (the identity binding is cleared
|
||||
separately via `anonymize()`).
|
||||
- **Authored-action events** (`RFC Proposed`, `PR Opened`,
|
||||
`Comment Posted`): carry `rfc_slug`. PRs additionally carry
|
||||
`pr_number` once the row exists. Comments additionally carry
|
||||
`thread_id`. None carry the body text.
|
||||
- **Admin-action events** (`Beta Access Requested`,
|
||||
`Admin Permission Decision`): the latter carries `action`
|
||||
(lowercase: `'grant'` / `'revoke'`) and `target_user_id`.
|
||||
- **Invite-side events** (`Invitation Sent`, `User Invited`): fire
|
||||
from the inviter's signed-in session. `Invitation Sent` (per-RFC,
|
||||
#12) carries `rfc_slug` + `role_in_rfc`. `User Invited`
|
||||
(admin-create, #16) carries `target_user_id` (the OHM user_id of
|
||||
the just-provisioned user) + `initial_role` +
|
||||
`custom_message_chars` (a bounded integer signal of admin
|
||||
effort, never the message text). Per #21 Part C: when the
|
||||
invitee is not yet a user (#12 per-RFC invitations to an email
|
||||
address that has never signed in), the invite-side event **MAY**
|
||||
carry a hashed `target_email` fingerprint (SHA-256 of the
|
||||
normalized lower-cased email) so the invite + claim pair can be
|
||||
correlated later. Plain-text `target_email` **MUST NOT** be
|
||||
carried.
|
||||
- **Claim-side events** (`Invitation Accepted`, `Invite Claimed`):
|
||||
fire from the invitee's session, immediately after an
|
||||
`identify({ user_id, properties })` call binds the OHM user_id
|
||||
to the Amplitude record (see §21.6). The events carry the
|
||||
invite context (`rfc_slug` + `role_in_rfc` for the per-RFC
|
||||
shape; `invited_by_admin_id` + `initial_role` + `needs_passcode`
|
||||
+ `trust_device` for the admin-create shape).
|
||||
|
||||
When in doubt, the principle: a property is correctly shaped iff
|
||||
the operator could publish it in a session transcript without
|
||||
hesitation.
|
||||
|
||||
### 21.3 Autocapture-friendly DOM patterns
|
||||
|
||||
The wrapper initializes Amplitude with `analytics.autocapture: true`,
|
||||
which auto-instruments page views, clicks, and form interactions.
|
||||
The *value* of those auto-captured events depends entirely on the
|
||||
DOM the SDK observes. Releases that add interactive UI **SHOULD**
|
||||
follow these patterns so the dashboard rows are readable rather
|
||||
than rows like "Click on `<button>` at `:nth-child(7)`".
|
||||
|
||||
- **Stable visible text on interactive elements.** Buttons and
|
||||
links **SHOULD** have stable, human-readable text content (the
|
||||
same string Amplitude uses to label the row). Avoid generic
|
||||
labels like "Read more" / "Click here" that lose context.
|
||||
- **`aria-label` on icon-only buttons.** Icon-only buttons (the
|
||||
chevron expanders, kebab menus, close `X`s) **MUST** carry a
|
||||
meaningful `aria-label`. Default autocapture for an unlabeled
|
||||
icon button reads as garbage. The `aria-label` is also an
|
||||
accessibility requirement — the two goals align.
|
||||
- **`data-amp-track-*` for repeating-list per-row identifiers.**
|
||||
When a list renders many rows of the same shape (RFC rows,
|
||||
comment rows, PR rows in a listing), per-row interactive elements
|
||||
**SHOULD** carry a `data-amp-track-name` attribute that
|
||||
identifies the row's *kind* and a `data-amp-track-*` attribute
|
||||
carrying the row's stable id. The convention:
|
||||
|
||||
```html
|
||||
<button
|
||||
data-amp-track-name="RFC Row Expand"
|
||||
data-amp-track-rfc-slug={slug}
|
||||
>…</button>
|
||||
```
|
||||
|
||||
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(<id>)`
|
||||
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
|
||||
<deployment> <SECRET_NAME>` (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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user