Status: implemented (issue #1739). What OpenCompany reports about how it is being used, what it deliberately never reports, which installs report at all, and how to turn it off.
The short version, and the only four sentences most readers need:
- The host process in a desktop or self-hosted install sends nothing. Not
"sends nothing by default" in the sense of a flag someone could flip in a
config file — the network client is behind a cargo feature the shipped
default build does not compile. Getting one out of that state takes a
recompile:
--features analyticsand an explicitOPENCOMPANY_ANALYTICS=on, both deliberate. The shared console separately uses OpenPanel's public browser client to record UI navigation; see the console note. See Configuration for the seven host-side conditions in full. - A hosted tenant — a container the OpenCompany platform provisioned and operates — reports shape and outcome only, under an opaque id.
- Nothing an operator or an agent wrote ever leaves the process this way. Not message text, prompts, file names, ledger values, tool arguments, addresses, or company names.
- The collector is OpenPanel, which is AGPL-3.0 and self-hosted by whoever runs the platform. There is no third party in the path and no default address — every reporting deployment names its own collector.
This repository is GPL-3.0 and self-hostable. An open-source instance that phones home by default is a betrayal of that, whatever the payload contains.
It is also the posture the rest of the tree already takes. tests/offline_e2e.rs runs inside a network namespace with no routes and asserts that the jail holds; offline.md says outright that a new cloud call on a shared path should turn that lane red, and that widening the namespace to make it pass is not an option. An analytics client firing at boot would do exactly that. The feature gate is what keeps both things true at once.
And roadmap.md's non-goal — "no private feedback backend" — still holds and is unchanged by this: feedback goes to public GitHub issues or stays local, and never rides this channel.
The same argument, one layer out. Silence-by-default answered "does a self-hosted instance report?" but left the destination a SaaS vendor, so the one deployment that did report — the hosted platform — shipped its usage to a third party, and a self-hoster who opted in had no way to run the other end. That is a licence taken more seriously by its users than by its own product.
OpenPanel is AGPL-3.0 and runs from a compose file. Collection now lands on infrastructure the platform operator runs, and a reader of this document can stand the whole stack up themselves. Mixpanel is gone entirely — not behind a flag, not as a fallback. There is one transport and it speaks to whatever OPENCOMPANY_ANALYTICS_ENDPOINT names.
The shared React console loads https://openpanel.dev/op1.js for a hosted tenant when the host explicitly sets OPENCOMPANY_ANALYTICS=on and supplies a safe OPENCOMPANY_ANALYTICS_ENDPOINT: it serves only the collector origin (never the configured path) in its non-cacheable same-origin /opencompany-config.js before the loader runs. A console served from another origin cannot read that host-local script automatically; set OPENCOMPANY_CONFIG.analytics: true and OPENCOMPANY_CONFIG.analyticsEndpoint in that static console instead, and allow that console origin at the OpenPanel collector. Desktop and default self-hosted builds remain silent, including when the opt-in has no endpoint. URLs with userinfo, query parameters, or fragments are never put in the browser configuration. Automatic outgoing-link and attribute collection stay disabled; the React lifecycle records only allowlisted screen names and button control types. This public id is not the host transport's OPENCOMPANY_ANALYTICS_CLIENT_ID / secret pair; never put that secret in a browser bundle.
Three events today. Each carries the context envelope below.
| Event | Fired when | Properties |
|---|---|---|
instance_started |
the host finished booting and registered its companies | companies (count), storage (fs/sqlite/mongodb), setup_complete |
turn_finished |
one cycle — the product's unit of work — ended | trigger, outcome (ok/failed), failure (coarse class), duration_ms, effects_executed, approvals_parked |
turn_metered |
one usage sample was recorded | sample_kind, provider, model (omitted when the sample named none), input_tokens, output_tokens, cached_input_tokens, cost_usd, attributed_to_run |
model is the closed ModelSlug vocabulary from
metering (issue #1749), already folded at the
harness — anthropic-sonnet, openai-gpt, chat-v1, …, or other for a model
this build cannot name. It is forwarded, never re-classified: ModelSlug's inner
value is a compiled-in literal, so a BYOK tenant's raw model name cannot reach a
payload even in principle, and analytics needs no classifier of its own for it.
The property is omitted when the sample named no model — every OAuth and
search call, a cognition path that cannot identify one, and any row written
before the field existed. Absent rather than other, so "no model ran" stays a
different answer from "a model ran that this build cannot name"; collapsing them
would inflate the other bucket with every tool call.
None of the three names collides with anything OpenPanel refuses.
session_start and session_end are its only reserved names, and its
anti-abuse blocklist rejects names over 80 characters, names containing a
newline, names beginning /, and a substring list (${, %{, ../,
union select, …). A refused event comes back 400 and becomes one debug!
line, so a future collision would look exactly like an instance quietly
reporting one fewer event — which is why no_event_name_is_one_the_collector_refuses
asserts it rather than leaving it to review.
Set once at boot, attached to every event:
| Property | Value |
|---|---|
deployment |
desktop | self-hosted | hosted-tenant |
app_version |
the crate version |
os, arch |
std::env::consts |
cognition_path |
harness | hosted | echo | sidecar | custom |
cognition_provider |
openrouter | subscription | managed | ollama | byok | … |
cognition_metering |
per-turn | per-cycle | none |
harness_in_build, mcp_in_build, acp_in_build, oauth_in_build, analytics_in_build |
the compiled feature set |
__timestamp |
when the event happened — see the wire contract |
The opaque identity is not in this table any more. It rides beside the
properties as OpenPanel's profileId rather than inside them as Mixpanel's
distinct_id property; the value is unchanged.
cognition_* is read off ports::brain::Cognition, the
descriptor the runtime already keeps, rather than re-derived from configuration
beside the code that picks a brain.
Message text. Prompts. Agent output. File and workspace paths. Ledger values and row contents. Tool names and tool arguments. Email addresses. MCP server names. Company names and ids. Agent names. Task titles. Error messages. Credentials of any kind.
This is a structural guarantee, not a review-time rule. A property value in this crate is one of exactly four things:
PropValue::Word(&'static str) | Count(u64) | Amount(f64) | Flag(bool)There is no String variant and no serde_json::Value. A &'static str cannot
be produced from runtime data without deliberately leaking memory, so every
textual property is a literal written in this repository. A call site with a
runtime string — a provider slug, an error, a trigger — must pass it through a
classifier that maps it onto a fixed list, and anything unrecognised becomes
other. The dangerous direction is a value nobody anticipated, so that is the
direction the design fails in.
Two consequences worth stating plainly, because both look like a bug until you know they are the point:
- An MCP tool call reports the provider
mcp, not the name the operator gave their server. That name is frequently a customer or a project. - A failure reports a coarse class (
store,refused,cognition, …), not the error message.Displayon this crate's error type embeds absolute host paths, company ids, tool names, ledger slugs and agent text — it is the richest source of user content in the tree.
src/analytics/test.rs asserts both halves: that hostile inputs do not survive
into a payload, and that every string anywhere in a rendered body is either
the opaque id, a platform constant, a word from a hand-written vocabulary, or
the one field that is none of those — __timestamp, which is checked against
the exact grammar YYYY-MM-DDTHH:MM:SSZ rather than waved through.
POST {OPENCOMPANY_ANALYTICS_ENDPOINT}, one request per event, JSON body,
authenticated by two headers. The full contract — the exact header names, the
discriminated-union body, the reserved event names, the response statuses, and
where a timestamp goes — is in
analytics-wire.md, read from OpenPanel's own source rather
than from its docs.
Two consequences of it shape everything below and are worth stating here: there is no batch endpoint, so the transport issues one request per event (see the drain); and the credential travels in headers rather than in the body, so there is no longer a code path by which it could reach a rendered payload.
profileId is opaque and stable, and it is one of two things:
-
t_<32 hex>— an HMAC-SHA256 of the tenant slug, for a hosted tenant, and only when the platform also suppliesOPENCOMPANY_ANALYTICS_ID_KEY. Hashed rather than passed through because a tenant slug is usually the customer's own brand. Every question analytics asks — uniques, funnels, segmentation, retention — needs only that the same tenant maps to the same value every time.Keyed, not a bare digest. The slug space is small, guessable and often public, so a plain SHA-256 is a lookup table away from being reversible: an attacker who can list plausible customer names recovers the mapping offline. The key removes that, at the cost of making it load-bearing — rotating it re-identifies every tenant and breaks retention continuity, so it is minted once per platform and kept.
Both halves are required.
boot::identifyemitst_only when a tenant namespace and the key are present; with either missing it falls back to thei_form below rather than emitting a weaker identity. A platform operator who omits the key therefore gets per-instance identity and per-instance retention, which is a quieter outcome than they may expect — the boot line does not call it out, and this is the place that says so. -
i_<32 hex>— this host's instance id otherwise: 16 random bytes minted on first boot and persisted under the data root (data-root.md). Random, not derived:src/app/instance.rsargues that at length, and the reasons are the same here.
One caveat, inherited from that module: on an unwritable data root the instance id cannot be persisted and a fresh one is minted per process, so such a host looks like a new install on every boot. It logs a warning when this happens. A read-only root is a misconfiguration (storage.md); the symptom in analytics is inflated install counts, not lost data.
| Variable | Meaning |
|---|---|
OPENCOMPANY_DEPLOYMENT |
desktop | self-hosted | hosted-tenant. Declared by whoever launches the process. Default and fallback: self-hosted, including when the declared value cannot be read. |
OPENCOMPANY_ANALYTICS |
on forces reporting; off forbids it and outranks everything else. |
OPENCOMPANY_ANALYTICS_CLIENT_ID |
the OpenPanel client id — a UUIDv4 naming a write or root client. |
OPENCOMPANY_ANALYTICS_CLIENT_SECRET |
that client's secret. Configuration, never a compiled-in constant — a secret baked into a public binary is a secret everyone has. |
OPENCOMPANY_ANALYTICS_ENDPOINT |
the collector URL. Required; there is no default. Must be an absolute URL with a host, and must be https — plain http is accepted only for a loopback host (127.0.0.0/8, ::1, localhost). |
OPENCOMPANY_ANALYTICS_ID_KEY |
the secret a hosted tenant's analytics id is derived under. Injected by the platform, never given to the collector. Absent means the host is known by its random instance id instead. |
For a self-hosted OpenPanel behind its bundled Caddy, the endpoint is
https://<your-domain>/api/track — the reverse proxy strips the /api prefix
before the API container sees it.
Reporting happens only when all of these hold:
- the binary was built with
--features analytics; OPENCOMPANY_ANALYTICSis notoff;- the deployment is
hosted-tenant, orOPENCOMPANY_ANALYTICS=on; - both halves of the client credential are configured;
- both halves are values that can go in an HTTP header;
- an endpoint is configured, and it is one a client could actually POST to;
- and it is one the credential can safely cross —
https, orhttpto loopback.
Mixpanel had one — https://api.mixpanel.com/track — and dropping it rather
than re-pointing it is the deliberate half of this change. A self-hosted
collector has no canonical address; it lives wherever its operator runs it. Any
default this crate picked would therefore be somebody else's collector, and a
tenant that configured a credential but forgot the endpoint would ship its
telemetry to a third party nobody named. That is the same accident condition 6
already refuses to make from the other direction, and it is worse, because the
boot line would name a destination that is perfectly real.
So an absent or blank endpoint is silence with its own reason, a malformed one a second and an insecure one a third: "you never set this", "what you set will not parse" and "what you set would leak the secret" are three different edits, and send an operator to three different places.
Mixpanel's token rode in the body of a request to one fixed https address this
crate chose; nothing could downgrade it. The OpenPanel secret is a header on
every request, to an address the operator types, so
OPENCOMPANY_ANALYTICS_ENDPOINT=http://collector.internal/track puts a
long-lived write credential on the wire in cleartext, once per event, for the
life of the tenant (CWE-319),
and a container cannot verify anyone's claim that the network in between is
private. So it is silence with its own reason, not a warning-and-send — a
warning is a line nobody reads while the secret ships anyway.
Loopback is the exception, and a real one rather than a concession: traffic
to 127.0.0.0/8, ::1 or localhost does not leave the host, so the secret
never crosses a network between machines — the claim CWE-319 is about, and
narrower than "nobody can see it" (a privileged local process can capture lo).
It is how the collector runs beside the workload in development, and how every
gated test here reaches its own. http://openpanel-api:3000/track
between two services on one Docker network is refused, so terminate TLS at
the collector (its bundled Caddy does, the documented deployment) or use
loopback; and localhost is matched by exact name, not by suffix (RFC 6761's
*.localhost is something a resolver only may honour). Configuration cannot
enforce this alone, so the client also follows no redirect and sends a
cleartext endpoint through no proxy — reqwest would otherwise route
http://localhost to HTTP_PROXY, off the host, in the clear:
analytics-wire.md.
OpenPanel authenticates a write client with an id and a secret, so there is
no useful state in between them. Half a credential is what a half-finished
secret rollout looks like — the id is in the manifest, the secret is still in
the vault — and an operator staring at "no credential is configured" while
OPENCOMPANY_ANALYTICS_CLIENT_ID is plainly set in their env file has been told
something that reads as false. So there are three reasons, not one: neither
half, no id, no secret.
Condition 5 is new with OpenPanel and exists because of where the credential
travels now. Mixpanel's token rode in the JSON body, where any string is legal,
so a mangled one was simply refused by the collector. These two ride in headers,
and reqwest will not build a request whose header value holds a control byte —
so a secret that picked up a newline in the middle (kubectl create secret over
a wrapped file is the usual way one arrives, and trimming does not save it)
would install a tracker that never constructs a single request, forever, behind
a debug! nobody has enabled.
The check is written by hand in config.rs rather than deferred to reqwest,
because that module is un-gated on purpose: the whole decision is provable in
the default build, where reqwest may not be in the dependency graph at all. It
is therefore deliberately stricter than HeaderValue — printable ASCII with
no space — so anything it accepts, the transport can send. That subset claim is
the only thing making a hand-written rule safe here, so the gated test
the_header_safety_check_is_a_subset_of_what_a_header_accepts asserts it
against HeaderValue::from_str directly, over every byte.
The endpoint, separately, is validated with url, the same parser reqwest
uses, rather than an approximation of the URL grammar. The first attempt
hand-rolled the check and accepted eight shapes reqwest rejects —
http://[::1/track, :99999, :65536, :abc, host:8080:9090, ]::1[,
127.0.0.1.5 and 999.999.999.999 — each of which resolved to reporting and
then dropped every batch. Issue #673 had already settled this rule for a
different call site: it must be the same parser reqwest uses, because a
second hand-rolled reader is a bypass waiting to be found.
Condition 1 is met in exactly one place in this repository: TENANT_FEATURES in
.github/workflows/deploy-staging.yml, the hosted tenant image's feature set.
Nothing else compiles the feature — not the desktop (crates/opencompany-app/Cargo.toml),
not the default build, not any CI lane but the scoped analytics one. A hosted
image whose feature list drops analytics reports nothing however the manager
configures it, and says so at boot rather than failing quietly.
OPENCOMPANY_TENANT_ID implies hosted-tenant when OPENCOMPANY_DEPLOYMENT
says nothing at all — the control plane injects it and nothing else does.
That is the only inference taken. A declaration that is present but unusable —
an unknown slug, or bytes this process cannot decode — is not "nothing": it wins
over the inference and resolves to self-hosted. Reading it through
EnvSource::get rather than get_os made a non-UTF-8 value indistinguishable
from an absent one, so an explicitly-declared shared-single-DB tenant fell
through to the inference and came back hosted-tenant — reporting switched
on by a malformed variable, on the discriminator every other decision here
rests on. A blank declaration is still absent, so a launcher that exports an
empty variable changes nothing. A discriminator sniffed from something
incidental (the data dir, the bind address, harness_in_build) inverts the day
someone changes an unrelated setting, silently, and points at the wrong file.
An unrecognised value for either switch resolves to silence, never to
reporting — on a hosted tenant too. Both directions of that typo matter and only
one is obvious. A typo must not upgrade an install into one that reports; it
must also not fail to downgrade one, which is what happened while an
unreadable value fell through to the deployment default: an operator who meant
OPENCOMPANY_ANALYTICS=off and typed of kept reporting, and their boot line
said "reporting to …" rather than anything that would send them back to look.
Silence is the answer to "I cannot tell what you asked for", and the boot line
names the reason. A blank value is treated as absent rather than unreadable,
so a launcher that exports an empty variable changes nothing.
Set OPENCOMPANY_ANALYTICS=off. It outranks the deployment kind and the
credential, and it is the first thing checked. Boot prints one line either way:
analytics: off (not a hosted tenant and no explicit opt-in)
analytics: off (operator opted out)
analytics: off (the OPENCOMPANY_ANALYTICS value is not recognised)
analytics: off (no collector credential is configured (OPENCOMPANY_ANALYTICS_CLIENT_ID and OPENCOMPANY_ANALYTICS_CLIENT_SECRET))
analytics: off (OPENCOMPANY_ANALYTICS_CLIENT_SECRET is not configured)
analytics: off (the configured collector credential contains bytes that cannot go in an HTTP header)
analytics: off (OPENCOMPANY_ANALYTICS_ENDPOINT is not configured)
analytics: off (the OPENCOMPANY_ANALYTICS_ENDPOINT value is not a usable http(s) URL)
analytics: off (reporting to https://collector.internal/track was configured, but this build was compiled without the `analytics` feature)
analytics: reporting to https://collector.internal/track
Every one of those exists because the alternative was a line that said
"reporting to …" over a process that then sent nothing. collector.internal/track
— a hostname written without a scheme, which is how anyone writes one the first
time — used to resolve to reporting, so boot announced a destination and every
batch died inside reqwest behind a debug! no operator has enabled. That
matters more now than it did: with no default endpoint, every reporting
deployment types that variable by hand.
The endpoint is named; the credential never is — and the endpoint is named
sanitized. A self-hosted collector is routinely reached through an
authenticated proxy, and such a proxy carries its key in the two places a URL
can hold one: userinfo (https://user:pass@host/track) and the query string
(?key=…), plus a third the other two miss — an opaque path segment, as in
https://collector.example/ingest/<token>. All three are stripped before the
line is printed, leaving scheme, host and leading path segment, and the line
says (credentials redacted) when it shortened anything: a silently truncated
URL is its own hour of confusion. The ClientCredentials redaction does not
cover this; it guards two different strings.
The same URL reaches one other log line: the debug! the transport writes when
a send fails. reqwest::Error retains the request URL and prints it, so an
unreachable collector wrote the proxy key into container logs by a path the boot
line's redaction never touched. Measured against reqwest 0.12.28, userinfo is
already stripped from what it prints and the query string is not — so
?key=… was leaking and user:pass@ was not. The transport calls without_url,
which removes the URL rather than rewriting it, so neither shape can reach the
line whatever a future reqwest decides to print; the destination on that same
line comes from the one loggable_endpoint helper the boot line uses, so there
is no second redaction to fall out of step with the first.
The second-to-last line is the one worth reading twice. It reports what the
process will do, not what was configured: a build without the analytics
feature resolves to reporting and then gets a NullTracker, because there is no
transport in it to hand back. Saying "reporting to …" there would be the exact
opposite of the truth, and the openpanel::build line that explains it is a
tracing::info! the CLI's default EnvFilter swallows — which is why every
boot line here is a println! in the first place.
A hosted tenant's profileId is an HMAC-SHA256 of its slug under
OPENCOMPANY_ANALYTICS_ID_KEY, truncated to 128 bits and prefixed t_.
It used to be a plain SHA-256(slug), and that did not deliver what it
promised. A hash only hides an input that cannot be guessed, and a tenant slug
is close to the opposite: it is usually the customer's brand, drawn from a
small, public, enumerable set. Anyone holding the digests — the collector
itself, or anyone with access to the analytics project — can hash a few thousand
candidate brands and read t_<digest> straight back to the customer. Truncation
does not help. Nor would a salt compiled into the binary, since this is a
GPL-3.0 crate and that salt would ship in every copy of the source.
There is no unkeyed fallback. When no key is configured the host is known by
its own random instance id (i_…, 128 random bits from app::instance), which
identifies nobody's customer. That is the safe direction: a host that cannot
identify its tenant privately identifies itself rather than identifying its
customer publicly. Every question the identity exists to serve — uniques,
funnels, segmentation, retention — is answered by either id, because the
instance id is persisted in the data root and is therefore stable across
restarts.
The consequence for the platform: until the manager injects
OPENCOMPANY_ANALYTICS_ID_KEY, hosted tenants report under instance ids rather
than tenant digests. Grouping several instances of one tenant together needs
the key; the manager can still correlate a digest back to a tenant itself,
because it holds both the key and the slug. The collector cannot.
| Seam | File | Why there |
|---|---|---|
turn_finished |
runtime::cycle::CycleRunner::run_bracketed |
The cycle's whole span, including the wait on the per-company serial lock — which is the part an operator experiences as "nothing is happening". |
turn_metered |
analytics::meter::TrackingUsageMeter, a decorator over the UsageMeter port |
Every metering::record_* path ends there, on every build. The harness cost hook is richer but openhuman-gated, and the cycle-level path deliberately reports zero tokens on that build so spend is not double-counted — so an event at either one is blind on the other half of the fleet. |
instance_started |
analytics::boot::install |
After companies register and after the port is bound: the company count and the cognition path are not known before the first, and a host that never took its address never started in any sense worth counting. |
| cognition relabel | Tracker::observe_cognition, from server::provision and runtime::rebuild |
Boot's answer stops being true in two ways. A hosted host provisioned into an empty registry had no runtime to read and recorded custom/unknown; and a company that configures inference for the first time is rebuilt in place (issue #290), which moves it from echo to harness. Most recent observation wins. Events already sent are not revised. |
flush |
src/bin/opencompany.rs, after the bound host stops serving |
The server has already drained, so a last-moment turn's event still leaves — bounded by shutdown::FLUSH_BUDGET, below. |
Cognition is a host-level label, and on a multi-company host it is
approximate. Inference is configured per company, so a host serving two
companies — one configured, one on the echo fallback — has two cognition paths
and one envelope, and whichever was observed last answers for both. Making it
exact means moving cognition off the envelope's super-properties and onto
turn_finished and turn_metered themselves, which changes the payload shape
this document describes and does not fit instance_started, which has no
company. That is an analytics-contract decision rather than a defect, raised in
review on PR #1751 and left for its own change.
The flush is bounded, and telemetry is what gives way. DEFAULT_GRACE
(25s) and CONNECTION_GRACE (2s) are sized to land at 27s, deliberately under
Kubernetes' default 30s terminationGracePeriodSeconds. The flush talks to a
collector this process does not control, with a 5s client timeout of its own, so
unbounded it took the worst case to 32s — buying a SIGKILL in the middle of the
drain those 27s exist to protect.
shutdown::flush_budget(drain) gives it whatever is left of the pod's default
30s after the drain and the connection window, capped at
shutdown::FLUSH_BUDGET (2s). Derived rather than added, because the drain is
configurable: a flat two seconds on top of OPENCOMPANY_SHUTDOWN_GRACE_SECONDS=28
— which fits in 30s exactly on its own — took it to 32 and recreated the same
SIGKILL, for a value the operator had every reason to think was safe. A drain
that already fills the budget leaves zero and the flush is skipped, and a flush
that does not finish is abandoned. A dropped event costs a line in a dashboard;
an overrun costs a half-finished turn.
Tracker::track is synchronous, infallible and returns nothing, so a call site
cannot await a network or branch on a telemetry error. A dead collector drops
events after one debug! line, the queue is bounded at 500 events, and a drain
that cannot reach the collector abandons the rest of itself rather than paying a
5s timeout per queued event. Three status classes — 401, 3xx, 429/5xx —
abandon it too, none being an answer about the event posted. The full reasoning,
and the one loss said out loud (the tail a cancelled shutdown flush drops), is in
analytics-wire.md.
Named so the gaps are countable rather than implied. Each is a follow-up, not an oversight:
- approvals, tools, workflows, ledgers, connections/MCP, and console views — the surfaces #1739 lists as candidates;
- build commit, which this crate does not stamp at build time;
- a timed flush shared with
MaintenanceTickerrather than the transport's own 30-second loop.
| Lane | Command |
|---|---|
| default build — the decision, the vocabulary, the payload builder, the meter decorator, the cycle hook | cargo test --locked |
| gated — the transport, and the acceptance criteria that need it | scripts/ci/run-scoped-suite.sh "analytics" analytics analytics |
scripts/ci/feature-lanes.txt records the second as partial, per the rule in
CLAUDE.md that every feature says which lane runs its
tests.
The two tests that matter most are a pair, and they only mean something
together: a_self_hosted_build_makes_no_request stands up a local collector,
hands the process a credential and an endpoint, declares no deployment, and
asserts zero requests; a_hosted_tenant_reports_with_the_full_envelope is
its positive control against the same collector, the same events and the same
code path with one variable changed. Without the second, a zero request count
would be indistinguishable from a test that never sends anything at all. The
positive control also pins the wire contract: two events, two requests, both
auth headers by their exact spelling, and the union body with the identity as
profileId.