Experiments (A/B) layer

A shared way to run an A/B test across Truth Social — and what each team is asked to build.
Proposal for review · DB · API · Janus · Clients · Data · 2026-08-28

Read this first

Today we have no way to run an A/B test. Every experiment that has shipped was built by hand, inside one product, with its own split logic and its own analytics. Nothing is reusable and nothing is comparable.

This proposes one generic experiments layer. An operator defines an experiment and its groups in Janus. A deterministic hash of the account id decides which group a user is in. Every analytics event carries the group the user was actually in when the event happened. The readout then slices any existing metric by group.

One place to define an experiment, one function that assigns users, and a group label on every analytics event. Nothing else changes.

Phase 1 is the config plane only — DB, API, and Janus. It delivers the ability to create and ramp an experiment and see it stored correctly. Nothing consumes an experiment yet, and no product behavior changes. That keeps the first review small and lets each of the three teams say yes to a self-contained piece.

Phase 2 wires up a consumer — sevro serves a branch, the clients echo the group onto events, and the data pipeline carries the label through to the readout. That work is described here so you can see where Phase 1 leads, but it is not what is being asked for now.

Most of Phase 1 is already written. The social-v1 schema and admin API are code-complete and green on MR !3834. The Janus admin UI is code-complete on MR !896. This document exists to get those reviewed and to agree the shape before Phase 2 starts.

Contents

  1. How it works
  2. Which services are involved
  3. Proposal — Database
  4. Proposal — social-v1 API
  5. Proposal — Janus
  6. Phase 2 — sevro, clients, data
  7. Decisions we need ruled on
  8. Where the work stands

How it works

Three ideas. Everything else follows from them.

1. Assignment is a pure function, never a stored row

A user's group is computed, not looked up. There is no assignment table and no write on the read path. We hash the account id with the experiment's salt to land the user in one of ten thousand buckets:

bucket = fnv32a( decimal_string( fnv32a( salt + account_id ) ) ) % 10000   # → [0, 9999]

This is the GrowthBook v2 FNV32a spec — about ten lines per runtime, and we run no GrowthBook software. The salt never changes after an experiment is created, so a user's bucket never changes for the life of that experiment.

2. Groups own bucket ranges, and ramping moves the ranges

Each group owns an explicit, contiguous slice of the bucket space, stored directly as range_start and range_end. Control is a real group row with its own range, not a leftover gap.

To ramp a group up, an operator re-slices the ranges. Buckets stay fixed and the group that owns a bucket changes.

The fill order is what keeps a ramp safe. Treatment arms anchor to opposite ends of the bucket space and grow inward toward control: the first arm fills up from 0, the second fills down from 9999, and control keeps the middle. So raising an arm's percentage trades buckets with control only — users never switch from one treatment arm to another.

LAYOUT v1 bucket 0 bucket 9999 group_a 0–999 control 1000–7999 group_b 8000–9999 grows inward grows inward operator raises group_a to 20% it takes buckets from control, never from group_b LAYOUT v2 group_a 0–1999 control 2000–7999 group_b 8000–9999 — unchanged
group_a — anchored low
control — holds the middle
group_b — anchored high
buckets that moved: control → group_a only
With three or more arms this guarantee weakens. Arms three and up stack immediately after the first arm, so re-slicing a multi-arm experiment can move users between arms. The operational rule is therefore: avoid changing rollout percentages on an experiment with 3+ groups. Control → arm moves are fine in nearly every case. The Janus save preview names every moved bucket span before you commit, so a ramp that would cause an arm-to-arm move is visible rather than silent.

3. Every event carries the group the user was actually in

If the readout asked "what group is this user in now", a ramp would retroactively rewrite history. So the group travels with the event instead. The serving endpoint tells the client which group it served under, and the client echoes that value onto every analytics event it sends.

The result: an engagement carries the group that served the post, not the group as of the tap. Attribution stays correct across any number of ramps.

Why the client carries the label rather than the server re-deriving it. Re-deriving at ingest would make correctness depend on trusting the event timestamp, which is fragile for buffered and offline mobile events. The trade-off is that a tampered label can only mis-slice that one user's own analytics rows. The label is per-user attribution, not a security gate. Phase 2 adds a sampled re-derive check in sevro as a mismatch metric to detect that, and an HMAC-signed tag is the documented escalation if it ever matters.

What this layer is not

In scope
  • Defining experiments and named groups
  • Deterministic server-side assignment
  • Manual ramping through an admin UI
  • A group label on every analytics event
  • An audit trail of every ramp
Out of scope
  • Automatic ramp or gate evaluation
  • Statistical significance calculation
  • Client-side assignment or hashing
  • Logged-out users
  • Per-group parameters (names only, for now)
This does not replace feature flags. A feature flag is a kill switch with boolean semantics. An experiment is a named-group split with an attached readout. The two stay orthogonal, and a flag can still kill-switch the feature an experiment tests.

Which services are involved

Config is written down the left. Assignment is read on the right.
PHASE 1 — CONFIG PLANE Janus experiments admin UI admin API social-v1 config store + admin API config write PostgreSQL 3 new configuration.* tables PHASE 2 — CONSUMPTION read (TTL) sevro assign · serve · intake X-Truth-Exp-Group ↑ Clients web · iOS · Android RMQ → clickhopper events ClickHouse experiment_labels — already shipped Metabase readout per group
Phase 1 — what this doc asks for
Phase 2 — deferred, dashed
Already shipped — no change needed

Two facts worth calling out. social-v1 is not on the analytics event path — it stores config and serves the admin API, nothing more. And the ClickHouse column already exists: the analytics pipeline shipped experiment_labels Array(LowCardinality(String)) on impressions, engagements, and sessions. This layer only fills a column that is already there.

Proposals by team

Phase 1 is the first three. Each is self-contained and reviewable on its own.

1 · Database

social-v1 · configuration schema code complete

Three new tables in the configuration schema. No changes to any existing table. No data migration. Assignment is never stored, so these tables hold configuration only and stay small — a handful of rows per experiment, plus one rollout row per group per ramp.

TableHoldsGrowth
experiments One row per experiment: name, salt, enabled flag, lock_version for optimistic locking. A few rows a quarter.
experiment_groups One row per group, including control (is_control). Unique on (experiment, name). 2–4 rows per experiment.
experiment_rollouts Time-ranged bucket ranges: range_start, range_end, effective_from, effective_to. One row per group per active period. One row per group per ramp. This is the audit trail.

Ramping is an UPDATE that closes the previous rows' effective_to plus an INSERT of the new ranges, both in one transaction. The current layout for a group is effective_from <= now() and (effective_to is null or effective_to > now()). Reading the layout as of any past moment uses the same predicate with a different timestamp, which is what makes the audit trail and the Phase 2 re-derive check possible.

Experiments are disabled, never hard-deleted. A disabled experiment means everyone is control.

The DDL follows the house conventions. Schema-qualified, CHECK constraints, a COMMENT on all sixteen columns, no IF NOT EXISTS guards. It ships as a paired up.sql / down.sql migration with pgTAP coverage in db/tests/configuration/tables/experiments.sql.
The ask

Review the DDL on MR !3834 and rule on two questions the shipped constraints leave open:

  • Range integrity. The CHECKs bound a single row only. Nothing at the DB level stops two groups' active ranges from overlapping, or requires the active set to cover all of [0, 9999]. The MR enforces this in the save service. Is service-level enforcement acceptable, or do you want an exclusion constraint or a trigger?
  • Salt immutability. Assignment stability depends on the salt never changing. There is no trigger enforcing it. Do you want one, or is API-level validation enough?

2 · social-v1 API

admin CRUD · no serving path code complete

One new admin controller, Api::V1::Admin::ExperimentsController, structurally mirroring the existing feature-flags admin vertical from MASTO-1578: controller, Panko serializers, routes, apidocs. Same authorization pattern — doorkeeper admin:read / admin:write scopes plus require_admin!.

The one piece with real logic is Configuration::SaveExperimentLayoutService. Saving a layout validates the ranges, closes the previous rollout rows, and inserts the new ones inside a single transaction. Concurrent edits collide on lock_version and return 409 rather than silently merging into a torn layout.

ComponentPrecedent it copies
Admin controller + routesfeature_flags_controller.rb
Panko serializersREST::Admin::FeatureFlagSerializer
AR models under Configuration::existing configuration-schema models
Apidocs entriesthe feature-flags documentation models
Branch note worth confirming. social-v1 has two diverging mainlines, and the feature-flags admin auth differs between them: develop uses admin-scoped doorkeeper plus require_admin!, while development uses the weaker :read/:write plus require_user!. The MR targets develop deliberately, so it inherits the stronger auth. Please confirm that is the branch you want this on.
The ask

Confirm the admin API shape and the develop target, then review MR !3834. It is green, conflict-free, and has no open threads. Also confirm that range validation living in the save service, rather than the DB, is where you want it — this is the same question the DB section asks, from the other side.

3 · Janus

admin UI · API-backed, no local tables code complete, in draft

An experiments admin section that mirrors the existing feature-flags vertical exactly. No Janus-local tables and no RabbitMQ — Janus reads and writes experiments only through the social-v1 admin API.

ComponentMirrors
TruthService::Experiments HTTP clienttruth_service/feature_flags.rb
Truth::Experiment API-backed modeltruth/feature_flag.rb
ExperimentsController + ERB/Hotwire viewsfeature_flags_controller.rb
ExperimentPolicy (Pundit)feature_flag_policy.rbPermission::ENGINEER

The one thing that is not a straight copy is the ramp editor. Operators think in percentages, not raw bucket indices, so the editor takes percentages and derives the ranges with the anchored fill order described above — first arm up from 0, second arm down from 9999, arms three and up stacked after the first, control holding the middle. Before saving, it shows a mandatory preview naming exactly which bucket spans move between which groups — so nobody ramps a group and discovers afterwards that users moved between arms. A 409 from the API surfaces as a conflict banner.

Already verified: zeitwerk check, template compilation under the real Rails handler, rubocop, and the bucket math against the design examples. Not yet verified: live requests and a browser pass, both of which need the social-v1 admin API deployed first. That is the only reason the MR is still marked draft.
The ask

Review the vertical on MR !896 for shape and convention fit — it is reviewable now. Confirm that Permission::ENGINEER is the right gate for creating and ramping an experiment, or name the permission you want instead. The end-to-end pass follows once the social-v1 API is deployed.

Phase 2 · sevro, clients, and data

for awareness — not being asked for yet deferred

Phase 1 gives us a config plane with nothing reading it. Phase 2 connects a real consumer. It is sketched here so each team can see what is coming and flag an objection early, but no commitment is being requested now.

A
sevro
  • Config repository — read the tables through the existing golib TTL cache pattern.
  • GroupFor — the assignment function in Go. Sole runtime that computes assignment.
  • Serve branch — branch a response on the group and return X-Truth-Exp-Group.
  • Re-derive check — sample impressions, compare to the client label, emit a mismatch metric.
Biggest chunk. Nothing started yet.
B
Clients
  • Read the X-Truth-Exp-Group response header.
  • Split it on ,.
  • Echo it verbatim as experiment_labels on analytics events.
Deliberately tiny. Clients never hash and never see a salt.
C
Data
  • clickhopper — a structural bound check on the label array (length cap, experiment:group shape). No config lookup.
  • ClickHouse — no change; the column already exists.
  • Metabase — flatten with ARRAY JOIN and a '' sentinel for the all-groups total.
The clickhopper check is blocked by nothing and could start any time.

The transport is a plain response header so the rule for every client is identical and endpoint-agnostic: echo what the server sent.

→ 200
X-Truth-Exp-Group: foryou_feed_mvp:treatment
   # multiple:  foryou_feed_mvp:treatment,ranking_v3:control
[ {…status…}, {…status…} ]        # body unchanged
One known cost, stated plainly. Because the label is client-carried, per-group readouts are populated only by upgraded clients until adoption saturates, and there is no server-side backfill. Early readings carry a selection bias toward users on current app versions. Treat per-group numbers as trustworthy only once client adoption is high.

Decisions we need ruled on

Everything else in this doc is settled. These are not — plus one already answered.

One smaller open item for Phase 2: sevro's config cache TTL and the client refresh cadence together size the ramp-transition window, during which events legitimately carry mixed groups. The rollout log gives the exact change boundaries, so a readout can exclude transitions if it wants to.

Where the work stands

Tracked under epic TMT-174, project "For You Feed — Improvements + Analytics".
TicketScopePhaseState
TMT-175social-v1 config schema + admin API1in review MR !3834 — green, no open threads
TMT-177Janus experiments admin UI1in progress MR !896 — code complete, draft
TMT-176sevro config reads + GroupFor2not started
TMT-178sevro serve branch + header2deferred
TMT-179sevro sampled re-derive check2not started
TMT-180clickhopper structural bound check2not started — blocked by nothing
TMT-181/182/183web / iOS / Android echo the header2blocked by TMT-178
TMT-184usage guide in the social-v1 wiki2blocked
All eleven tickets are currently unassigned, and the project has no lead and no target date. That is the practical reason for circulating this document. Phase 1 is written and waiting on review; Phase 2 needs named owners before it can be scheduled at all.

What each team can do next