feat(telemetry): transmit the stable install_id as-is

Product-owner decision, 2026-08-27: the analytical need is stable
cross-window identity (retention curves, longitudinal install
behaviour), which the rotating pseudonym destroyed by design. The
feature has not shipped - zero consented users, zero production
transmissions - so identity semantics can change without breaking any
promise made to a user; existing (dev-only) consent windows carry
forward unchanged.

Removed in full rather than weakened in place:
- shared_metrics_identity.py (salt generation/rotation, HMAC-SHA256
  derivation, payload substitution) and its 19-test file.
- The sender's derivation step. _freeze_identity keeps its validation
  role (unreadable/non-object/id-less payloads still reject rather than
  block the queue) and now records the raw install_id in
  sent_install_id; _body rewrites the payload's install_id from that
  frozen column, keeping byte-identical resends anchored to one
  recorded value.

Consent surface updated in the same change: the setup wizard now states
plainly that packages carry the stable profile-scoped install ID (a
random UUID, no personal information, reset by deleting the
shared-metrics directory). No consent was ever collected under the old
wording in any shipped build.

Docs A.2/A.3 rewritten as decision records rather than silently
edited: A.2 records what is transmitted now and states the
consequences plainly (indefinite cross-package correlation is the
designed behaviour); A.3 records why rotation existed and why its
removal was accepted. The main-body "must not reuse the persistent
local identifier by default" escape hatch is exercised, not deleted:
that paragraph required exactly this product decision, which has now
been made. A.6's deletion note updated: install_id is now itself the
lookup key, so a future delete-on-request needs only a service-side
API, not a mapping.

Tests: the two privacy assertions invert deliberately
(test_the_stable_install_id_is_transmitted_as_is and the e2e wire
variant); freezing/byte-identical-retry coverage unchanged. Staging
E2E script now asserts transmitted == install_id.

258 targeted tests pass; ruff + footguns clean; both staging E2E
harnesses green with the raw id observed on the wire (202s).
This commit is contained in:
Ben Barclay
2026-08-28 15:22:16 +10:00
parent ecf327c872
commit a69a9c351d
9 changed files with 119 additions and 413 deletions
+73 -65
View File
@@ -230,17 +230,18 @@ packages from that profile and can therefore link those local packages.
Deleting `$HERMES_HOME/telemetry/shared_metrics` resets the identifier together
with all aggregates and package files.
Remote delivery is opt-in and off by default. A remote exporter must not reuse
the persistent local identifier by default. It requires a separate product and
privacy decision covering consent, identity scope, rotation or keyed
pseudonymization, reset behavior, retention, and deletion.
Remote delivery is opt-in and off by default. Reusing the persistent local
identifier remotely required a separate product and privacy decision covering
consent, identity scope, reset behavior, retention, and deletion — that
decision has been made.
> Those decisions are recorded in
> [Appendix A](#appendix-a-remote-exporter-decisions-phase-2), and the exporter
> implementing them has shipped. Collection alone still transmits nothing: the
> sender runs only when `telemetry.shared_metrics.send` is also true, and it
> transmits a rotating HMAC of the install identity rather than the identifier
> itself.
> sender runs only when `telemetry.shared_metrics.send` is also true. Each
> transmitted package carries the stable `install_id` as-is (product decision,
> 2026-08-27 — see A.2 for the record, including the superseded
> HMAC-pseudonym design).
The install identity is scoped to one `HERMES_HOME`. To reset it, stop Hermes
processes and remove `$HERMES_HOME/telemetry/shared_metrics`. This deliberately
@@ -327,60 +328,66 @@ Local history can be up to 30 days old, and that data was collected under a
promise that nothing is uploaded. Honouring consent forward-only costs at most
30 days of backlog we never had permission to send.
### A.2 Identity scope — the transmitted identifier is derived, not the local one
### A.2 Identity scope — the stable install_id is transmitted as-is
`install_id` is the persistent profile-scoped identifier described above. It is
**not transmitted**. Each package sent carries a derived value instead:
**Decision record.** The original design of this exporter (and revisions 1–8
of this appendix) transmitted a keyed pseudonym instead of the identifier:
`HMAC-SHA256(key = locally-held rotating salt, message = install_id)`, with
the salt rotating every 30 days. On **2026-08-27**, before the feature
shipped (zero consented users, zero production transmissions), the product
owner decided the analytical need is a **stable cross-window identity** —
retention curves, longitudinal install behaviour — which rotation by design
destroys. The pseudonymization layer was removed in full rather than
weakened in place.
```text
transmitted_id = HMAC-SHA256(key = rotation_salt, message = install_id)
```
What is transmitted now:
- `rotation_salt` is random, generated locally, and never leaves the machine.
- The derivation is one-way: the service cannot recover `install_id`.
- Within a rotation window, packages from one profile correlate — so distinct
installs remain countable, which is the primary analytical question.
- Across windows, they do not.
- Each package carries `install_id` verbatim: the persistent, profile-scoped
random UUID described above.
- It is generated locally (`uuid4`), contains no hardware, account, user, or
machine-derived information, and identifies a *profile*, not a person.
- It is stable until the user deletes the shared-metrics directory, which
regenerates it (see A.4).
This satisfies "must not reuse the persistent local identifier by default"
while keeping the data useful. Stripping the identifier entirely was rejected
because "how many installs are reporting" is the first question the data must
answer; sending `install_id` unchanged was rejected because it contradicts the
commitment made above.
Consequences stated plainly rather than papered over:
**Byte-identical resends still hold.** The derived value is computed **once**,
when the package is first prepared for sending, and stored alongside the
package (the derived id only — not a second copy of the payload, which is
recomputed deterministically from the stored package). A retry therefore
rebuilds identical bytes even if the salt rotated in between. The contract
requires this: resending a `package_id` with different content is undefined
behaviour.
- Packages from one profile correlate **indefinitely**, not per-window.
Long-term linkability of one install's daily envelope sequence is now the
designed behaviour, not a residue.
- The A.3 residue analysis of the old design (stable `resource` tuple +
contiguous periods bridging rotation windows) is moot — there is no window
boundary left to bridge.
- The setup wizard's consent language states this identity model explicitly;
it was updated in the same change that removed the derivation, so no
consent was ever collected under the old wording in any shipped build.
### A.3 Rotation
**Byte-identical resends still hold.** The transmitted id is recorded on the
row (`sent_install_id`) when the package is first prepared, and the wire body
is always rebuilt from that recorded value, so a retry rebuilds identical
bytes. The contract requires this: resending a `package_id` with different
content is undefined behaviour. (With a stable id the recorded copy is no
longer load-bearing against rotation — it remains as the audit column and as
cheap insurance against any future change to identity semantics.)
`rotation_salt` rotates on a fixed schedule (default: every 30 days, aligned to
local history retention). Rotation only affects packages prepared after it;
already-prepared packages keep their derived value so retries stay
byte-identical.
### A.3 Rotation — removed (decision record)
Rotation bounds long-term linkability without destroying short-term cohort
analysis. A profile is one identity for the length of a window, and an
unrelated identity after it.
Salt rotation was deleted together with the derivation (product decision,
2026-08-27). This section is retained as a record of what the earlier design
did and why the removal was accepted:
**What rotation does not bound.** The identifier changes; the rest of the
envelope does not. `resource` (`os_family`, `architecture`, `install_method`,
`hermes_version`) is stable and low-entropy, and `period_start` /
`period_end` are contiguous across a rotation boundary. For a common
configuration this is no help to an observer — measured against the 11 real
packages in a development outbox, every one shares the same
`arm64 / macos / git` tuple. For a **rare** configuration it is a plausible
re-identification aid: an unusual architecture or install method, combined
with an uninterrupted daily period sequence, can bridge two windows. The
claim this design makes is therefore "rotation raises the cost of long-term
correlation", not "rotation makes it impossible". Narrowing that residue
would mean coarsening `resource` or jittering period boundaries, and neither
is worth the analytical loss today — but it should be a conscious decision,
not an unexamined one.
- Rotation existed to bound long-term linkability: one identity per 30-day
window, unrelated identities across windows.
- The documented residue (see git history for the full analysis): the
envelope's stable, low-entropy `resource` tuple plus contiguous daily
periods could plausibly bridge windows for rare configurations anyway, so
the boundary was a cost-raiser, not a wall.
- The product need that killed it: cross-window continuity is precisely what
retention analysis requires. A boundary that mostly inconveniences honest
analysis while only raising costs for a determined correlator was judged
the wrong trade once stable identity became a requirement.
There is no salt in the store, no rotation schedule, and no derived
identifier anywhere in the pipeline.
### A.4 Reset behavior
@@ -388,11 +395,11 @@ Removing `$HERMES_HOME/telemetry/shared_metrics` still resets local identity,
aggregates, and package files, exactly as documented above. Two honest
qualifications now apply:
- Reset also discards `rotation_salt`, so subsequent packages derive a **new**
transmitted identity. Local reset does give a new remote identity.
- Reset regenerates `install_id`, so subsequent packages transmit a **new**
identity. Local reset does give a new remote identity.
- Reset **cannot unsend**. Packages already transmitted remain in the ingest
service's storage under their derived identifier. There is no read-back or
delete API in the v1 contract.
service's storage under the identifier they were sent with. There is no
read-back or delete API in the v1 contract.
Setting `send: false` stops transmission immediately: consent is re-read
before every package, so a pass already in flight stops after the package it
@@ -439,13 +446,13 @@ invent one. What a user can do:
|---|---|
| `send: false` | No further packages leave the machine |
| `enabled: false` | Collection stops; existing local state remains |
| Remove `.../shared_metrics` | Local identity, aggregates, and files reset; future sends use a new derived identity |
| Remove `.../shared_metrics` | Local identity, aggregates, and files reset; future sends use a new install_id |
| Delete already-sent data | Not self-service — requires an operator acting on the S3 bucket |
If a deletion-on-request obligation is ever taken on, it needs a lookup path
from a user to their derived identifiers. That is deliberately **not** built:
it would require retaining the mapping this design exists to avoid. Any such
change is a new product decision, not an implementation detail.
If a deletion-on-request obligation is ever taken on, the lookup path is now
direct: the user's `install_id` (readable from their local store) is the key
their data is stored under. Building the service-side delete API remains a
new product decision, not an implementation detail.
### A.7 What the outbox directory is
@@ -464,7 +471,8 @@ state they were promised. Send state lives in new columns on the
### A.8 Scope note
The `install_id` field inside the package body is what gets replaced by the
derived value. No other payload field changes, nothing is added, and the
service treats the whole body as opaque. Payload schema evolution therefore
stays a sender-side concern, as before.
The `install_id` field inside the package body is transmitted as the
generator wrote it (rewritten from the row's frozen `sent_install_id`, which
records the same value). No other payload field changes, nothing is added,
and the service treats the whole body as opaque. Payload schema evolution
therefore stays a sender-side concern, as before.
+3 -2
View File
@@ -373,8 +373,9 @@ class SharedMetricsStore:
# Earliest next attempt; enforces backoff across process restarts.
("next_attempt_at", "TEXT"),
("last_error", "TEXT"),
# The derived identifier actually transmitted, frozen on the first
# attempt so retries stay byte-identical across a salt rotation.
# The identifier actually transmitted, frozen on the first
# attempt so retries stay byte-identical. Since the 2026-08-27
# product decision this is the stable install_id itself.
# Only the ~36-byte id is stored: the body is recomputed from
# payload_json, whose serialisation is deterministic.
("sent_install_id", "TEXT"),
@@ -1,131 +0,0 @@
"""Keyed pseudonymization of the shared-metrics install identity.
``install_id`` is a persistent, profile-scoped identifier. It is deliberately
NOT transmitted: ``docs/observability/relay-shared-metrics.md`` commits that a
remote exporter "must not reuse the persistent local identifier by default".
Each transmitted package instead carries::
HMAC-SHA256(key=rotation_salt, message=install_id)
where ``rotation_salt`` is generated locally, never leaves the machine, and
rotates on a fixed schedule. Within a rotation window the value is stable, so
distinct installs stay countable — the primary analytical question. Across
windows it changes, bounding long-term linkability.
The derivation is one-way: the service cannot recover ``install_id`` from what
it receives.
See Appendix A.2 and A.3 of the doc above for the decision record.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
#: Salt lifetime. Matches local history retention so the two ages line up.
ROTATION_INTERVAL = timedelta(days=30)
#: ``telemetry_state`` keys. The salt lives in the same store as install_id, so
#: deleting the shared-metrics directory resets both together — the documented
#: reset behaviour keeps working without a second cleanup path.
SALT_KEY = "send_rotation_salt"
SALT_ISSUED_AT_KEY = "send_rotation_salt_issued_at"
_SALT_BYTES = 32
def _isoformat(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _parse(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _read(connection: sqlite3.Connection, key: str) -> str | None:
row = connection.execute(
"SELECT value FROM telemetry_state WHERE key = ?", (key,)
).fetchone()
if row is None:
return None
# sqlite3.Row and plain tuples both index by position.
return str(row[0])
def _write(connection: sqlite3.Connection, key: str, value: str) -> None:
connection.execute(
"""
INSERT INTO telemetry_state(key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""",
(key, value),
)
def current_salt(
connection: sqlite3.Connection,
*,
now: datetime | None = None,
) -> str:
"""Return the active salt, generating or rotating it when due.
Must be called inside a write transaction: it can write to
``telemetry_state``.
"""
moment = now or datetime.now(timezone.utc)
salt = _read(connection, SALT_KEY)
issued_at = _parse(_read(connection, SALT_ISSUED_AT_KEY))
fresh = (
salt is not None
and issued_at is not None
# Strictly within the window. A future issued_at means the clock moved
# backwards (or the value was tampered with), so the recorded age
# cannot be trusted and we reissue rather than keep using a salt of
# unknown vintage. Reissuing is the safe direction: it shortens
# linkability, and already-prepared packages keep their frozen
# identifier so retries stay byte-identical.
and issued_at <= moment < issued_at + ROTATION_INTERVAL
)
if fresh:
return str(salt)
salt = secrets.token_hex(_SALT_BYTES)
_write(connection, SALT_KEY, salt)
_write(connection, SALT_ISSUED_AT_KEY, _isoformat(moment))
return salt
def derive_install_id(install_id: str, salt: str) -> str:
"""Return the transmitted identifier for ``install_id`` under ``salt``."""
return hmac.new(
salt.encode("utf-8"),
install_id.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def substitute_install_id(payload: dict, derived: str) -> dict:
"""Return ``payload`` with its ``install_id`` replaced by ``derived``.
This is the ONLY field the exporter changes. Everything else is
transmitted exactly as the generator wrote it, so payload schema evolution
stays a sender-side concern. A shallow copy is enough — only a top-level
key is replaced — and the caller's dict is left untouched.
"""
updated = dict(payload)
updated["install_id"] = derived
return updated
@@ -39,12 +39,6 @@ from datetime import datetime, timedelta, timezone
from hermes_cli.sqlite_util import write_txn
from .shared_metrics_identity import (
current_salt,
derive_install_id,
substitute_install_id,
)
logger = logging.getLogger(__name__)
#: Contract recommends timing out at 30s and treating a timeout as retryable.
@@ -432,13 +426,17 @@ class SharedMetricsSender:
payload_json,
now: datetime,
) -> str | None:
"""Derive and persist the transmitted id, or reject an unusable row.
"""Record the transmitted id on the row, or reject an unusable one.
Returns None when the package can never be sent. Rejecting rather than
raising matters: an exception here rolls back the claim transaction
and blocks every healthy package behind this one.
The stable install_id is transmitted as-is (product decision,
2026-08-27 — see the doc's A.2). What remains of "freezing" is the
validation and the audit column: ``sent_install_id`` records exactly
what the wire will carry, and rejecting unusable rows here rather
than raising matters because an exception rolls back the claim
transaction and blocks every healthy package behind this one.
"""
reason = None
install_id = None
try:
payload = json.loads(payload_json)
except (TypeError, ValueError):
@@ -467,24 +465,26 @@ class SharedMetricsSender:
)
return None
salt = current_salt(connection, now=now)
derived = derive_install_id(payload["install_id"], salt)
connection.execute(
"UPDATE package_outbox SET sent_install_id = ? WHERE package_id = ?",
(derived, package_id),
(install_id, package_id),
)
return derived
return str(install_id)
# -- transmission ------------------------------------------------------
def _body(self, payload_json: str, derived: str) -> bytes:
def _body(self, payload_json: str, transmitted_id: str) -> bytes:
"""Rebuild the exact bytes to send.
The payload is recomputed from the stored package rather than kept as
a second copy: json.dumps with these options is deterministic, and the
only mutable input (the derived id) is frozen in the row.
a second copy: json.dumps with these options is deterministic. The
install_id is written from the frozen ``sent_install_id`` column
rather than trusted implicitly, keeping "a resend is byte-identical"
anchored to one recorded value.
"""
payload = substitute_install_id(json.loads(payload_json), derived)
payload = json.loads(payload_json)
payload = dict(payload)
payload["install_id"] = transmitted_id
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
def _mark(
+6 -4
View File
@@ -2464,10 +2464,12 @@ def setup_telemetry(config: dict):
print_success("Local shared metrics enabled.")
print_info("")
print_info("Sending uploads each daily package to the Nous telemetry")
print_info("service. Your profile-scoped install ID is NOT sent: packages")
print_info("carry a rotating HMAC of it instead. Only packages from the")
print_info("day you opt in onwards are ever sent, and sending can be")
print_info("turned off again at any time.")
print_info("service. Packages carry your profile-scoped install ID, a")
print_info("stable random UUID that identifies this profile across days")
print_info("(it contains no personal information and is reset by deleting")
print_info("the shared-metrics directory). Only packages from the day you")
print_info("opt in onwards are ever sent, and sending can be turned off")
print_info("again at any time.")
shared_metrics["send"] = prompt_yes_no(
"Send shared metrics to Nous?",
default=shared_metrics.get("send") is True,
+7 -5
View File
@@ -171,10 +171,12 @@ def main() -> int:
print(f" last_error : {row[5]}")
if row[1] != "sent":
failures.append(f"{row[0]} is {row[1]}: {row[5]}")
if row[4] == real_install_id:
failures.append(f"{row[0]} LEAKED the real install_id")
if not row[4] or len(str(row[4])) != 64:
failures.append(f"{row[0]} has a malformed derived id")
# Product decision 2026-08-27: the stable install_id is transmitted
# as-is; the transmitted value must be exactly the local id.
if row[4] != real_install_id:
failures.append(
f"{row[0]} transmitted {row[4]!r}, expected the install_id"
)
print()
if failures:
@@ -183,7 +185,7 @@ def main() -> int:
print(f" ✗ {failure}")
return 1
print("PASS: every package acknowledged 202 with a derived identifier.")
print("PASS: every package acknowledged 202 with the stable install_id.")
print()
print("Verify the objects in S3 with the package ids above:")
print(" aws s3 ls --recursive "
@@ -1,179 +0,0 @@
"""Tests for keyed pseudonymization of the shared-metrics install identity.
The load-bearing property: install_id must never be transmitted, and the
value that IS transmitted must stay stable for a package even across a salt
rotation, or a retry would change the body under an already-used package_id.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
import pytest
from hermes_cli.observability.shared_metrics_identity import (
ROTATION_INTERVAL,
SALT_ISSUED_AT_KEY,
SALT_KEY,
current_salt,
derive_install_id,
substitute_install_id,
)
INSTALL_ID = "12a73e97-4de9-4766-830d-9ca1192c0420"
T0 = datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc)
@pytest.fixture
def connection():
conn = sqlite3.connect(":memory:")
conn.execute(
"CREATE TABLE telemetry_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
yield conn
conn.close()
class TestSaltLifecycle:
def test_first_call_generates_a_salt(self, connection):
salt = current_salt(connection, now=T0)
assert len(salt) == 64 # 32 bytes hex
assert int(salt, 16) >= 0 # valid hex
def test_salt_is_stable_within_the_window(self, connection):
first = current_salt(connection, now=T0)
later = current_salt(connection, now=T0 + timedelta(days=29, hours=23))
assert first == later
def test_salt_rotates_after_the_interval(self, connection):
first = current_salt(connection, now=T0)
after = current_salt(connection, now=T0 + ROTATION_INTERVAL + timedelta(seconds=1))
assert first != after
def test_salt_is_persisted(self, connection):
salt = current_salt(connection, now=T0)
stored = connection.execute(
"SELECT value FROM telemetry_state WHERE key = ?", (SALT_KEY,)
).fetchone()[0]
assert stored == salt
def test_issued_at_is_recorded(self, connection):
current_salt(connection, now=T0)
stored = connection.execute(
"SELECT value FROM telemetry_state WHERE key = ?", (SALT_ISSUED_AT_KEY,)
).fetchone()[0]
assert stored.startswith("2026-08-26T12:00")
def test_two_installs_get_different_salts(self):
salts = set()
for _ in range(5):
conn = sqlite3.connect(":memory:")
conn.execute(
"CREATE TABLE telemetry_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
salts.add(current_salt(conn, now=T0))
conn.close()
assert len(salts) == 5, "salts must be random per install, not derived"
def test_clock_rollback_reissues_rather_than_trusting_the_stamp(self, connection):
"""A future issued_at means the clock moved; the age is unknowable.
Reissuing is the safe direction — it shortens linkability rather than
extending it, and packages already prepared keep their frozen id.
"""
first = current_salt(connection, now=T0)
rolled_back = current_salt(connection, now=T0 - timedelta(days=5))
assert rolled_back != first
def test_corrupt_issued_at_reissues_rather_than_crashing(self, connection):
current_salt(connection, now=T0)
connection.execute(
"UPDATE telemetry_state SET value = 'not-a-date' WHERE key = ?",
(SALT_ISSUED_AT_KEY,),
)
assert current_salt(connection, now=T0) is not None
class TestDerivation:
def test_derivation_is_deterministic(self):
salt = "a" * 64
assert derive_install_id(INSTALL_ID, salt) == derive_install_id(INSTALL_ID, salt)
def test_derivation_hides_the_install_id(self):
derived = derive_install_id(INSTALL_ID, "a" * 64)
assert INSTALL_ID not in derived
assert derived != INSTALL_ID
def test_different_salts_give_different_values(self):
assert derive_install_id(INSTALL_ID, "a" * 64) != derive_install_id(
INSTALL_ID, "b" * 64
)
def test_different_installs_give_different_values(self):
salt = "a" * 64
assert derive_install_id(INSTALL_ID, salt) != derive_install_id("other", salt)
def test_output_shape_is_sha256_hex(self):
derived = derive_install_id(INSTALL_ID, "a" * 64)
assert len(derived) == 64
int(derived, 16)
class TestSubstitution:
def _package(self):
return {
"schema_version": "hermes.shared_metrics.v2",
"package_id": "3a63d27e-f170-4d4c-8c4d-ebd80feac592",
"install_id": INSTALL_ID,
"generated_at": "2026-08-26T01:01:25.311956Z",
"period_start": "2026-08-26T00:00:00Z",
"period_end": "2026-08-27T00:00:00Z",
"resource": {"hermes_version": "0.20.5", "os_family": "macos"},
"metrics": [{"name": "hermes.client.active", "type": "counter", "value": 1}],
}
def test_install_id_is_replaced(self):
result = substitute_install_id(self._package(), "derived-value")
assert result["install_id"] == "derived-value"
def test_no_other_field_changes(self):
original = self._package()
result = substitute_install_id(original, "derived-value")
for key in original:
if key != "install_id":
assert result[key] == original[key]
def test_the_caller_dict_is_not_mutated(self):
original = self._package()
substitute_install_id(original, "derived-value")
assert original["install_id"] == INSTALL_ID
def test_no_fields_are_added_or_removed(self):
original = self._package()
assert set(substitute_install_id(original, "x")) == set(original)
def test_the_raw_install_id_never_survives_substitution(self):
import json
body = json.dumps(substitute_install_id(self._package(), "derived-value"))
assert INSTALL_ID not in body
class TestRetryStability:
"""The property that keeps retries contract-compliant."""
def test_a_frozen_derived_id_survives_a_rotation(self, connection):
salt_before = current_salt(connection, now=T0)
frozen = derive_install_id(INSTALL_ID, salt_before)
# Time passes, the salt rotates, and the package is retried.
salt_after = current_salt(connection, now=T0 + ROTATION_INTERVAL + timedelta(days=1))
assert salt_after != salt_before
# Rebuilding from the FROZEN value reproduces identical bytes; deriving
# afresh would not.
assert substitute_install_id({"install_id": INSTALL_ID}, frozen) == {
"install_id": frozen
}
assert derive_install_id(INSTALL_ID, salt_after) != frozen
@@ -374,15 +374,19 @@ class TestConsentGate:
class TestIdentity:
def test_install_id_is_never_transmitted(self, store):
def test_the_stable_install_id_is_transmitted_as_is(self, store):
"""Product decision 2026-08-27: no pseudonymization.
The wire body carries the profile-scoped install_id verbatim. This
test is the deliberate inversion of the pre-decision assertion that
the raw id never crossed the wire.
"""
_add_package(store, "pkg-1", "2026-08-26")
transport = FakeTransport(FakeResponse(202))
_sender(store, transport).send_pending()
raw = transport.calls[0]["payload"].decode("utf-8")
assert INSTALL_ID not in raw
assert transport.bodies[0]["install_id"] != INSTALL_ID
assert transport.bodies[0]["install_id"] == INSTALL_ID
def test_derived_id_is_frozen_on_the_row(self, store):
def test_transmitted_id_is_frozen_on_the_row(self, store):
_add_package(store, "pkg-1", "2026-08-26")
transport = FakeTransport(FakeResponse(503), FakeResponse(202))
_sender(store, transport).send_pending()
@@ -171,12 +171,11 @@ class TestRealTransport:
).fetchone()[0]
assert state == "sent"
def test_the_install_id_never_crosses_the_wire(self, store, server):
def test_the_stable_install_id_crosses_the_wire_as_is(self, store, server):
"""Product decision 2026-08-27: the raw install_id is transmitted."""
_add(store, "pkg-1", metrics=40)
_sender(store, server).send_pending()
body = json.dumps(Ingest.received[0]["body"])
assert INSTALL_ID not in body
assert len(Ingest.received[0]["body"]["install_id"]) == 64
assert Ingest.received[0]["body"]["install_id"] == INSTALL_ID
def test_content_type_is_json(self, store, server):
_add(store, "pkg-1")