fix(app): gate single-source message hover behind an experiment (#2761)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 15:37:37 +02:00
1 parent 69c42cff90
commit 1281045648
30 files changed
+2107 -360

No files matched your search

@@ -36,6 +36,7 @@ Missing settings use the defaults documented below. Invalid stored configuration
| gateway_rollout | [Gateway rollout configuration](#gateway-rollout-configuration-object) object | Gateway admission and dispatch tuning |
| voice_noise_suppression | [voice noise suppression configuration](#voice-noise-suppression-configuration-object) object | Client-side noise suppression rollout |
| experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on |
| message_hover_tracking | [message hover tracking configuration](#message-hover-tracking-configuration-object) object | Message hover implementation rollout |
| registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations |
| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
| app_public | [public application configuration](#public-application-configuration-object) object | Branding, legal, setup, and registration field policy |
@@ -136,6 +137,27 @@ Every field is present on read. An absent document or missing field uses the def
Both fields are served to every account, whether or not any experiment targets that account, and neither one is versioned by `config_version`. A client that has never received a response from the experiments route uses built-in defaults of 300 seconds and 15 percent, which equal the defaults above. A client that cannot read the route never receives either field.
## Message hover tracking configuration object
The instance rollout of the message hover implementation the client runs in the message list. [Experiments](/http-api/experiments/) defines what a client resolves from it.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| enabled | boolean | Whether the rollout runs at all (default false) |
| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request |
| rollout_basis_points | integer | Share of accounts drawn, in basis points (0-10000, default 0) |
| rollout_salt | string | Salt of the sampling hash (1-64 characters, default `message-hover-tracking-v1`) |
| included_user_ids | array[snowflake] | Accounts always drawn, up to 1000 entries (default empty) |
| excluded_user_ids | array[snowflake] | Accounts never drawn, up to 1000 entries (default empty) |
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so an account in both is never drawn. A drawn client resolves the hovered message from one shared pointer oracle, and a client that is not drawn keeps the per-row implementation it ships with. Neither arm changes any response this API produces.
How often a client revalidates this rollout is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) above.
## Registration configuration object
Registration policy in force, plus every issued registration URL and every account awaiting a decision.
@@ -526,6 +548,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti
| gateway_rollout? | object | Any subset of the [Gateway rollout configuration](#gateway-rollout-configuration-object) fields, each bound as documented there |
| voice_noise_suppression? | object | Any subset of the [noise suppression](#voice-noise-suppression-configuration-object) fields |
| experiment_delivery? | object | Any subset of the [experiment delivery](#experiment-delivery-configuration-object) fields |
| message_hover_tracking? | object | Any subset of the [message hover tracking](#message-hover-tracking-configuration-object) fields |
| registration? | object | `mode` and `admin_registration_urls_enabled` |
| app_public?<sup>2</sup> | object | `branding`, `setup`, `legal`, and `registration` sub-objects, each merged field by field |
| integrations?<sup>3</sup> | object | `gif`, `youtube`, `captcha`, `email`, and `bluesky` sub-objects, the last of which also has the `keys` array |
@@ -538,6 +561,8 @@ The body has one optional object for each section. Fluxer leaves an absent secti
`voice_noise_suppression` takes every [voice noise suppression configuration](#voice-noise-suppression-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
`message_hover_tracking` takes every [message hover tracking configuration](#message-hover-tracking-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises that section's own `config_version` by one on each request that supplies at least one of them, independently of the noise suppression revision. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
`experiment_delivery` takes both [experiment delivery configuration](#experiment-delivery-configuration-object) fields, each bound as documented there. It is a section of its own, so a write to it changes no `config_version` and changes no assignment, only the cadence on which clients ask for one.
<sup>3</sup> A secret such as `klipy_api_key`, `api_key`, `hcaptcha_secret_key`, `turnstile_secret_key`, or the SMTP `password` is written when supplied and left alone when absent. `integrations.bluesky.keys` is the only way to write the Bluesky signing keys counted as `bluesky.key_count`. It takes up to 8 entries of `kid` (1-255 characters) and nullable `private_key` (up to 10000 characters), and replaces the stored key set outright
@@ -574,7 +599,7 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on
| 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` |
:::caution[Sections are applied one after another]
The order is `gateway_rollout`, `voice_noise_suppression`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
The order is `gateway_rollout`, `voice_noise_suppression`, `message_hover_tracking`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
:::
### Side effects
@@ -1,12 +1,12 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Experiments
description: The experiment assignments envelope, the revalidation and polling contract, and the noise suppression experiment.
description: The experiment assignments envelope, the revalidation and polling contract, and the experiments this server defines.
---
import RouteHeader from '@/components/RouteHeader.astro';
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. `voice_noise_suppression` is the only experiment defined today, and [Voice](/voice/) defines the placement protocol its assignment applies to.
An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, and `message_hover_tracking`, which selects one of two client implementations of the message hover state.
Every assignment is advice. A client that ignores one behaves as it does with the rollout off, and no route and no Gateway event reports what a client actually ran.
@@ -33,10 +33,11 @@ One entry per experiment. The envelope reports this object even when it is empty
| Field | Type | Description |
| --- | --- | --- |
| voice_noise_suppression? | [noise suppression assignment](#noise-suppression-assignment-object) object | The caller's noise suppression assignment |
| message_hover_tracking? | [message hover tracking assignment](#message-hover-tracking-assignment-object) object | The caller's message hover tracking assignment |
Ignore unknown experiments and treat a missing experiment as off.
This server version writes `voice_noise_suppression` on every response, including while the rollout is disabled. The disabled value is the first [resolution outcome](#resolution-outcomes) below, which reports `enabled` false and the stored `config_version`, so a client that compares `config_version` with the value from its previous response can see that an operator saved the noise suppression configuration, even while the rollout stays disabled, and needs no second request for it.
This server version writes `voice_noise_suppression` and `message_hover_tracking` on every response, including while a rollout is disabled. The disabled value is the first [resolution outcome](#resolution-outcomes) below, which reports `enabled` false and the stored `config_version`, so a client that compares `config_version` with the value from its previous response can see that an operator saved the configuration, even while the rollout stays disabled, and needs no second request for it.
## Noise suppression backends
@@ -102,6 +103,33 @@ One backend replacement scoped to one guild. While the caller is connected to a
An override naming a backend that is absent from `enabled_backends` is dropped before the response is written, so every entry is runnable.
## Message hover tracking assignment object
One resolution of the instance message hover tracking rollout against one account. Every field is present whenever the key is written.
The rollout selects which implementation of the message hover state a client runs. A drawn client resolves the hovered message from one shared pointer oracle and drives the message action bar from that state. A client that is not drawn keeps the per-row implementation it ships with. Neither arm changes what the API returns, and no route reports which arm a client ran.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| enabled | boolean | Whether the rollout is running on this instance |
| config_version | integer | The revision of the instance configuration this assignment was resolved from |
| user_targeted | boolean | Whether the caller is inside the rollout |
| source | ?string | Which rule targeted the caller, one of `user_rule` or `canary`, and null where the caller is not targeted |
### Hover tracking resolution outcomes
The outcomes below set `user_targeted` to false, and they differ in what else they report.
1. The rollout is off. `enabled` is false and `source` is null.
2. The operator has excluded the caller. `enabled` is true and `source` is null.
3. The caller was not drawn. `enabled` is true and `source` is null.
A caller is drawn either by the operator's allowlist, which sets `source` to `user_rule`, or by the sampled share of the account population, which sets `source` to `canary`. The blocklist is read before the allowlist, so an account named in both is not drawn.
`config_version` reports the stored revision in all outcomes, the rollout being off included. A client branches on `user_targeted` alone.
## Get experiment assignments
<RouteHeader method="GET" path="/v1/experiments" bot />