# Side-Effects Review — Threadline Identity-Discovery Unification

**Slug:** `threadline-identity-discovery-unification`
**Date:** 2026-05-28
**Author:** Echo (instar developer)
**Spec:** `docs/specs/threadline-identity-discovery-unification.md` (converged iter 2, approved)

## Summary of the change

Discovery was advertising an identity the agent's own relay does not answer to.
`announcePresence` wrote `agent-info.json.publicKey` from the orphan
`threadline/identity-keys.json` hex key, and `/threadline/health` reported
`identityPub` from the HandshakeManager key (empty on some agents) — while the
relay client registers (and routes) by the canonical `identity.json`
fingerprint. A peer who discovered the agent got a non-routable address, so
their messages vanished (their side `sent=true`, no "Accepted message from …"
on the receiver). Fleet-wide for any agent whose canonical identity was
generated independently of its legacy keys.

The fix advertises the **routing identity** everywhere, internally consistent:
`agent-info.json` and `/threadline/health` now publish a `fingerprint`
(routable relay address) plus a `publicKey`/`identityPub` set to the SAME
canonical key, sourced from `IdentityManager.get()` — the exact read-only API
the relay client uses, so the two can never re-diverge.

**Files changed (source):**
- `src/threadline/AgentDiscovery.ts` — `fingerprint` field on `AgentInfoFile`,
  `ThreadlineAgentInfo`, `HealthResponse`; `announcePresence` accepts +
  persists it; `pingThreadlineHealth`/`verifyAgent` propagate it.
- `src/threadline/ThreadlineBootstrap.ts` — resolve `IdentityManager.get()` up
  front; advertise canonical `publicKey`(hex)+`fingerprint`+`machine`; omit both
  when `get()` is null (no fabrication).
- `src/threadline/ThreadlineEndpoints.ts` — `/threadline/health` sources
  `identityPub`+`fingerprint` from `IdentityManager.get()`; `stateDir` added to
  config; falls back to the handshake key + omits fingerprint when no routing
  identity resolves.
- `src/server/routes.ts` — pass `stateDir` to `createThreadlineRoutes`.
- `src/core/PostUpdateMigrator.ts` — `migrateThreadlineAgentInfoIdentity`
  (belt-and-suspenders fleet repair) + `migrateClaudeMd` routing-fingerprint
  guidance section.
- `src/scaffold/templates.ts` — CLAUDE.md template "What address reaches me"
  section (Agent Awareness).
- `src/data/builtin-manifest.json` — regenerated by the build (template hashes).

**Files changed (tests):** `ThreadlineBootstrap.test.ts`,
`ThreadlineEndpoints.test.ts` (+3 new: migrator unit, health integration,
bootstrap wiring e2e).

**Drive-by (unrelated, green-keeping):** `src/commands/test-as-self.ts` line 60
— `new URL(import.meta.url).pathname` → `fileURLToPath(import.meta.url)`. This
is the URL-pathname-path-encoding pattern (breaks when the project path
contains spaces) that the pre-push gate (`scripts/pre-push-gate.js`) flags; it
was introduced into `test-as-self.ts` by an earlier merge and the pre-push gate
blocks any push until it is fixed (the check is pre-push-only, not in CI, which
is why it landed). One-line fix to the documented-correct pattern; no behavior
change.

## Blast radius / consumers

- **`agent-info.json.publicKey` encoding** is unchanged (hex) and now sourced
  from the canonical key. Consumers (`verifyAgent` does `Buffer.from(.., 'hex')`
  + 32-byte length check) still work; the key is a valid 32-byte Ed25519 key.
- **`/threadline/health.identityPub`** switches from the handshake key to the
  canonical routing key WHEN one resolves. Safe: `verifyAgent` does not perform
  a real cryptographic challenge (nonce generated, never exchanged), and the E2E
  handshake exchanges keys inline — it does not read `agent-info.json` or the
  health `identityPub`. Verified by reading `ThreadlineEndpoints.ts` handshake
  paths + `AgentDiscovery.verifyAgent`.
- **No-fabrication boundary:** a relay-disabled / no-identity / locked-encrypted
  agent now OMITS `fingerprint`+`publicKey` instead of advertising a key for an
  identity on no relay. This is a behavior change for brand-new agents on their
  very first announce (before the relay client creates an identity) — correct
  per spec (not relay-discoverable until a routing identity exists), self-heals
  on the next boot once the identity is created.
- **Migrator** only rewrites `agent-info.json` when `get()` resolves AND the
  file is misaligned; no-ops otherwise (idempotent, atomic write, never
  fabricates).

## Migration parity

Existing agents: the in-`announcePresence` rewrite self-heals on the
post-update server restart; `migrateThreadlineAgentInfoIdentity` closes the
narrow update-without-restart window; `migrateClaudeMd` adds the guidance for
agents whose CLAUDE.md predates this. New agents get all of it via `init` +
the template.

## Out of scope (tracked, not deferred)

- Multi-machine same-fingerprint advertisement coordination →
  `project_cross_machine_seamlessness_spec`. This fix sets the `machine` field
  (attributable) and is neutral for multi-machine.
- Retiring the orphan `identity-keys.json` / collapsing the 3–4 keypairs →
  `bug_threadline_identity_divergence_discovery`. This fix stops *advertising*
  the orphan; deleting it touches the handshake layer and needs its own spec.

## Risk

Low. Additive at the field level; `agent-info.json` is regenerated on every
boot regardless. Reverting removes the field + migrator with no data loss. All
three test tiers + wiring + both-sides green; full `test:push` suite green.
