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 Deleting `$HERMES_HOME/telemetry/shared_metrics` resets the identifier together
with all aggregates and package files. with all aggregates and package files.
Remote delivery is opt-in and off by default. A remote exporter must not reuse Remote delivery is opt-in and off by default. Reusing the persistent local
the persistent local identifier by default. It requires a separate product and identifier remotely required a separate product and privacy decision covering
privacy decision covering consent, identity scope, rotation or keyed consent, identity scope, reset behavior, retention, and deletion — that
pseudonymization, reset behavior, retention, and deletion. decision has been made.
> Those decisions are recorded in > Those decisions are recorded in
> [Appendix A](#appendix-a-remote-exporter-decisions-phase-2), and the exporter > [Appendix A](#appendix-a-remote-exporter-decisions-phase-2), and the exporter
> implementing them has shipped. Collection alone still transmits nothing: the > implementing them has shipped. Collection alone still transmits nothing: the
> sender runs only when `telemetry.shared_metrics.send` is also true, and it > sender runs only when `telemetry.shared_metrics.send` is also true. Each
> transmits a rotating HMAC of the install identity rather than the identifier > transmitted package carries the stable `install_id` as-is (product decision,
> itself. > 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 The install identity is scoped to one `HERMES_HOME`. To reset it, stop Hermes
processes and remove `$HERMES_HOME/telemetry/shared_metrics`. This deliberately 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 promise that nothing is uploaded. Honouring consent forward-only costs at most
30 days of backlog we never had permission to send. 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 **Decision record.** The original design of this exporter (and revisions 1–8
**not transmitted**. Each package sent carries a derived value instead: 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 What is transmitted now:
transmitted_id = HMAC-SHA256(key = rotation_salt, message = install_id)
```
- `rotation_salt` is random, generated locally, and never leaves the machine. - Each package carries `install_id` verbatim: the persistent, profile-scoped
- The derivation is one-way: the service cannot recover `install_id`. random UUID described above.
- Within a rotation window, packages from one profile correlate — so distinct - It is generated locally (`uuid4`), contains no hardware, account, user, or
installs remain countable, which is the primary analytical question. machine-derived information, and identifies a *profile*, not a person.
- Across windows, they do not. - 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" Consequences stated plainly rather than papered over:
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.
**Byte-identical resends still hold.** The derived value is computed **once**, - Packages from one profile correlate **indefinitely**, not per-window.
when the package is first prepared for sending, and stored alongside the Long-term linkability of one install's daily envelope sequence is now the
package (the derived id only — not a second copy of the payload, which is designed behaviour, not a residue.
recomputed deterministically from the stored package). A retry therefore - The A.3 residue analysis of the old design (stable `resource` tuple +
rebuilds identical bytes even if the salt rotated in between. The contract contiguous periods bridging rotation windows) is moot — there is no window
requires this: resending a `package_id` with different content is undefined boundary left to bridge.
behaviour. - 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 ### A.3 Rotation — removed (decision record)
local history retention). Rotation only affects packages prepared after it;
already-prepared packages keep their derived value so retries stay
byte-identical.
Rotation bounds long-term linkability without destroying short-term cohort Salt rotation was deleted together with the derivation (product decision,
analysis. A profile is one identity for the length of a window, and an 2026-08-27). This section is retained as a record of what the earlier design
unrelated identity after it. did and why the removal was accepted:
**What rotation does not bound.** The identifier changes; the rest of the - Rotation existed to bound long-term linkability: one identity per 30-day
envelope does not. `resource` (`os_family`, `architecture`, `install_method`, window, unrelated identities across windows.
`hermes_version`) is stable and low-entropy, and `period_start` / - The documented residue (see git history for the full analysis): the
`period_end` are contiguous across a rotation boundary. For a common envelope's stable, low-entropy `resource` tuple plus contiguous daily
configuration this is no help to an observer — measured against the 11 real periods could plausibly bridge windows for rare configurations anyway, so
packages in a development outbox, every one shares the same the boundary was a cost-raiser, not a wall.
`arm64 / macos / git` tuple. For a **rare** configuration it is a plausible - The product need that killed it: cross-window continuity is precisely what
re-identification aid: an unusual architecture or install method, combined retention analysis requires. A boundary that mostly inconveniences honest
with an uninterrupted daily period sequence, can bridge two windows. The analysis while only raising costs for a determined correlator was judged
claim this design makes is therefore "rotation raises the cost of long-term the wrong trade once stable identity became a requirement.
correlation", not "rotation makes it impossible". Narrowing that residue
would mean coarsening `resource` or jittering period boundaries, and neither There is no salt in the store, no rotation schedule, and no derived
is worth the analytical loss today — but it should be a conscious decision, identifier anywhere in the pipeline.
not an unexamined one.
### A.4 Reset behavior ### 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 aggregates, and package files, exactly as documented above. Two honest
qualifications now apply: qualifications now apply:
- Reset also discards `rotation_salt`, so subsequent packages derive a **new** - Reset regenerates `install_id`, so subsequent packages transmit a **new**
transmitted identity. Local reset does give a new remote identity. identity. Local reset does give a new remote identity.
- Reset **cannot unsend**. Packages already transmitted remain in the ingest - Reset **cannot unsend**. Packages already transmitted remain in the ingest
service's storage under their derived identifier. There is no read-back or service's storage under the identifier they were sent with. There is no
delete API in the v1 contract. read-back or delete API in the v1 contract.
Setting `send: false` stops transmission immediately: consent is re-read Setting `send: false` stops transmission immediately: consent is re-read
before every package, so a pass already in flight stops after the package it 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 | | `send: false` | No further packages leave the machine |
| `enabled: false` | Collection stops; existing local state remains | | `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 | | 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 If a deletion-on-request obligation is ever taken on, the lookup path is now
from a user to their derived identifiers. That is deliberately **not** built: direct: the user's `install_id` (readable from their local store) is the key
it would require retaining the mapping this design exists to avoid. Any such their data is stored under. Building the service-side delete API remains a
change is a new product decision, not an implementation detail. new product decision, not an implementation detail.
### A.7 What the outbox directory is ### 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 ### A.8 Scope note
The `install_id` field inside the package body is what gets replaced by the The `install_id` field inside the package body is transmitted as the
derived value. No other payload field changes, nothing is added, and the generator wrote it (rewritten from the row's frozen `sent_install_id`, which
service treats the whole body as opaque. Payload schema evolution therefore records the same value). No other payload field changes, nothing is added,
stays a sender-side concern, as before. 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. # Earliest next attempt; enforces backoff across process restarts.
("next_attempt_at", "TEXT"), ("next_attempt_at", "TEXT"),
("last_error", "TEXT"), ("last_error", "TEXT"),
# The derived identifier actually transmitted, frozen on the first # The identifier actually transmitted, frozen on the first
# attempt so retries stay byte-identical across a salt rotation. # 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 # Only the ~36-byte id is stored: the body is recomputed from
# payload_json, whose serialisation is deterministic. # payload_json, whose serialisation is deterministic.
("sent_install_id", "TEXT"), ("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 hermes_cli.sqlite_util import write_txn
from .shared_metrics_identity import (
current_salt,
derive_install_id,
substitute_install_id,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Contract recommends timing out at 30s and treating a timeout as retryable. #: Contract recommends timing out at 30s and treating a timeout as retryable.
@@ -432,13 +426,17 @@ class SharedMetricsSender:
payload_json, payload_json,
now: datetime, now: datetime,
) -> str | None: ) -> 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 The stable install_id is transmitted as-is (product decision,
raising matters: an exception here rolls back the claim transaction 2026-08-27 — see the doc's A.2). What remains of "freezing" is the
and blocks every healthy package behind this one. 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 reason = None
install_id = None
try: try:
payload = json.loads(payload_json) payload = json.loads(payload_json)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -467,24 +465,26 @@ class SharedMetricsSender:
) )
return None return None
salt = current_salt(connection, now=now)
derived = derive_install_id(payload["install_id"], salt)
connection.execute( connection.execute(
"UPDATE package_outbox SET sent_install_id = ? WHERE package_id = ?", "UPDATE package_outbox SET sent_install_id = ? WHERE package_id = ?",
(derived, package_id), (install_id, package_id),
) )
return derived return str(install_id)
# -- transmission ------------------------------------------------------ # -- 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. """Rebuild the exact bytes to send.
The payload is recomputed from the stored package rather than kept as The payload is recomputed from the stored package rather than kept as
a second copy: json.dumps with these options is deterministic, and the a second copy: json.dumps with these options is deterministic. The
only mutable input (the derived id) is frozen in the row. 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") return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
def _mark( def _mark(
+6 -4
View File
@@ -2464,10 +2464,12 @@ def setup_telemetry(config: dict):
print_success("Local shared metrics enabled.") print_success("Local shared metrics enabled.")
print_info("") print_info("")
print_info("Sending uploads each daily package to the Nous telemetry") 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("service. Packages carry your profile-scoped install ID, a")
print_info("carry a rotating HMAC of it instead. Only packages from the") print_info("stable random UUID that identifies this profile across days")
print_info("day you opt in onwards are ever sent, and sending can be") print_info("(it contains no personal information and is reset by deleting")
print_info("turned off again at any time.") 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( shared_metrics["send"] = prompt_yes_no(
"Send shared metrics to Nous?", "Send shared metrics to Nous?",
default=shared_metrics.get("send") is True, default=shared_metrics.get("send") is True,
+7 -5
View File
@@ -171,10 +171,12 @@ def main() -> int:
print(f" last_error : {row[5]}") print(f" last_error : {row[5]}")
if row[1] != "sent": if row[1] != "sent":
failures.append(f"{row[0]} is {row[1]}: {row[5]}") failures.append(f"{row[0]} is {row[1]}: {row[5]}")
if row[4] == real_install_id: # Product decision 2026-08-27: the stable install_id is transmitted
failures.append(f"{row[0]} LEAKED the real install_id") # as-is; the transmitted value must be exactly the local id.
if not row[4] or len(str(row[4])) != 64: if row[4] != real_install_id:
failures.append(f"{row[0]} has a malformed derived id") failures.append(
f"{row[0]} transmitted {row[4]!r}, expected the install_id"
)
print() print()
if failures: if failures:
@@ -183,7 +185,7 @@ def main() -> int:
print(f" ✗ {failure}") print(f" ✗ {failure}")
return 1 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()
print("Verify the objects in S3 with the package ids above:") print("Verify the objects in S3 with the package ids above:")
print(" aws s3 ls --recursive " 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: 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") _add_package(store, "pkg-1", "2026-08-26")
transport = FakeTransport(FakeResponse(202)) transport = FakeTransport(FakeResponse(202))
_sender(store, transport).send_pending() _sender(store, transport).send_pending()
raw = transport.calls[0]["payload"].decode("utf-8") assert transport.bodies[0]["install_id"] == INSTALL_ID
assert INSTALL_ID not in raw
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") _add_package(store, "pkg-1", "2026-08-26")
transport = FakeTransport(FakeResponse(503), FakeResponse(202)) transport = FakeTransport(FakeResponse(503), FakeResponse(202))
_sender(store, transport).send_pending() _sender(store, transport).send_pending()
@@ -171,12 +171,11 @@ class TestRealTransport:
).fetchone()[0] ).fetchone()[0]
assert state == "sent" 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) _add(store, "pkg-1", metrics=40)
_sender(store, server).send_pending() _sender(store, server).send_pending()
body = json.dumps(Ingest.received[0]["body"]) assert Ingest.received[0]["body"]["install_id"] == INSTALL_ID
assert INSTALL_ID not in body
assert len(Ingest.received[0]["body"]["install_id"]) == 64
def test_content_type_is_json(self, store, server): def test_content_type_is_json(self, store, server):
_add(store, "pkg-1") _add(store, "pkg-1")