Skip to content

fix(identity): run the diverged-room heal on the daemon clock, not only on airc join - #1316

Draft
joelteply wants to merge 7 commits into
canaryfrom
fix/identity-heal-autonomous-reconcile
Draft

fix(identity): run the diverged-room heal on the daemon clock, not only on airc join#1316
joelteply wants to merge 7 commits into
canaryfrom
fix/identity-heal-autonomous-reconcile

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

The mechanism behind "airc is broken again"

Room UUIDs are UUIDv5(mesh_identity ‖ NUL ‖ channel_name). The mesh identity is resolved locally: gh api usergit config user.email → a fabricated local:<host>:<user>.

A machine that boots before gh can answer mints the provisional identity and derives every room UUID under it. Its #general is a different room from the account's #general. It publishes beacons the account never reads, reads a room nobody writes, and reports a healthy join throughout. Two machines, each its own island, no error anywhere. This is on record in the tree — headers_keys.rs names the live incident: bigmama's #general derived under local:unknown-host:unknown-user while M5 derived under the gh login.

The identity does self-heal (provisional sources re-probe gh on every resolve). The state derived from it does not.

What was already there, and what was missing

SubscriptionSet::rebind_diverged is the correct repair — written, documented, unit-tested. Its only callers are join and ensure_join_context. So the heal only ran when a human typed the join. Its own doc comment says so: "the scope read a dead room until a manual airc stop && airc join." That manual step is the recurring ritual.

Change

  • Airc::reconcile_subscription_identity — resolve the identity, re-derive each subscription's room UUID from its stored NAME, rebind what diverged, persist, re-publish presence so peers route to the converged room, and return the rebinds so the caller is loud per old→new move.
  • Called from refresh_routes_once, the daemon's 60s route-refresh tick.

Idempotent and free when converged: zero rebinds → no store write, no beacon churn. On an unhealed node the tick re-probes gh once a minute, so reconvergence lands within ~60s of gh becoming reachable, with nobody typing anything.

Test

tests/identity_heal_reconcile.rs pins the three things the rebind_diverged unit test cannot:

  1. persisted — a fresh load (what the separate daemon process sees) carries the converged UUID; without the write the heal evaporates every tick
  2. idempotent — a converged scope reports zero rebinds
  3. name / wire / join-time survive; only the derived UUID moves

It also reproduces the divergence: joining under a pinned local:... identity derives a demonstrably different room UUID than the healed one. Hermetic — pins an Operator-source identity, never shells out to gh, and a temp-rooted scope is its own account boundary so it cannot touch ~/.airc.

Not fixed here

The root design still lets a machine fabricate its own account namespace when it cannot authenticate. Detect-and-resolve now works autonomously; never-fabricate — refusing to mint a private mesh, or adopting the identity from the authenticated peer set — is the deeper change and wants its own decision.

Relates to #288 (self-healing mandate: no human ever runs the fix).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

joelteply and others added 7 commits August 4, 2026 01:29
…ly on `airc join`

The recurring "airc is broken again, run `airc stop && airc join`" has a single
mechanism behind it, and the heal for it was already written — it just never ran
without a human.

Room UUIDs are UUIDv5(mesh_identity ‖ NUL ‖ channel_name). The mesh identity is
resolved locally: `gh api user` → `git config user.email` → a fabricated
`local:<host>:<user>`. A machine that boots before gh can answer (no network yet,
keychain locked, service context with no env — bigmama resolved literally
`local:unknown-host:unknown-user`) mints the provisional identity and derives
EVERY room UUID under it. Its `#general` is then a different room from the
account's `#general`: it publishes beacons the account never reads, reads a room
nobody writes, and reports a perfectly healthy join the whole time. Two machines,
each its own island, no error anywhere.

The identity itself self-heals — provisional sources re-probe gh on every resolve.
The state derived from it does not. `SubscriptionSet::rebind_diverged` is the
correct repair and has been in tree, documented and unit-tested, but its only
callers are `join` and `ensure_join_context`. So recovery required a human typing
the join. That manual step IS the five-month ritual.

This wires the same heal into the daemon's route-refresh tick (60s):

- `Airc::reconcile_subscription_identity` — resolve the identity, re-derive every
  subscription's room UUID from its stored NAME, rebind what diverged, persist,
  and re-publish presence so peers resolve routes to the converged room. Returns
  the rebinds so the caller is loud about each old→new move.
- Called from `refresh_routes_once`, before endpoint/route work.

Idempotent and free when converged: zero rebinds means no store write and no
beacon churn, so a healthy node pays one cached identity read per tick. On an
unhealed node the tick re-probes gh once a minute, so reconvergence lands within
~60s of gh becoming reachable — with nobody typing anything.

Test (`tests/identity_heal_reconcile.rs`) pins the three things the existing
`rebind_diverged` unit test cannot: the heal is PERSISTED (a fresh load — what the
daemon process sees — carries the converged UUID; without the write the heal
evaporates every tick), it is idempotent, and name/wire/join-time survive. It also
reproduces the divergence itself: joining under a pinned `local:...` identity
derives a demonstrably different room UUID than the healed one.

Not fixed here, and worth naming: the root design still lets a machine fabricate
its own account namespace when it cannot authenticate. Detect-and-resolve now
works; never-fabricate is the deeper change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ever invent one

Identity derives from the human owner's GitHub login. Not from the box. A
machine cannot know who its human is by looking at itself, and every place the
old code let it try produced a private mesh that only that machine could see.

The resolution chain used to be gh → `git config user.email` → a fabricated
`local:<host>:<user>`. The last two are MACHINE facts, not owner facts. Since
room UUIDs are UUIDv5(mesh_identity ‖ NUL ‖ channel_name), either one mints a
namespace nobody else derives: the node publishes beacons the account never
reads, reads a room nobody writes, and reports a healthy join throughout. That
is bigmama's `#general` under `local:unknown-host:unknown-user` while the M5
derived under the gh login — every frame between them dying as
`unknown_channel`, with no error anywhere.

New chain, and it does not end in a guess:

1. `AIRC_MESH_IDENTITY` — the OWNER stating their own login. The one substitute
   for the gh probe, because it is the owner speaking rather than the machine
   guessing. Escape hatch for boxes with no gh (offline, container, CI).
2. A fresh cached `gh_api_user`.
3. `gh api user --jq .login`.
4. A STALE cached `gh_api_user` when gh is unreachable — a stale copy of the key
   is still the key; the owner's login does not change because the network did.
   Erroring here would drop a converged machine off its own mesh over a
   transient outage.
5. Otherwise `MeshIdentityError::Unresolved`. Loud, and NOTHING is persisted —
   a stored guess would outlive the outage and keep deriving private rooms.

A legacy `git_email` / `local_host_user` row is treated as unusable rather than
"stable": reusing it is what stranded a machine on its own `#general`. With gh
reachable it heals to the login; without, it is refused.

Deleted `local_fallback_identity` + its injectable core and the four tests that
pinned the fabrication's shape. `machine_id()` stays — a machine key is a
legitimate per-machine fact (gist writer key); it is just not an identity.

Tests: the fabrication tests are replaced by their inverses — resolve ERRORS and
persists nothing when nothing can name the owner; a legacy provisional row is
refused when gh is unavailable; and the opposite mistake is pinned too, that an
expired gh login survives an unreachable gh. 333 lib tests + every integration
suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… every machine

Joel, 2026-08-04: "All derive from my gh user. Not computer." and "I could add a
zillion computers and as long as we all had the same gh user, I have one asha,
one human, one general room."

Rooms already satisfy that: `room_id = UUIDv5(owner_login ‖ NUL ‖ channel_name)`,
so every machine of one owner computes the same `#general` with no coordination.
CITIZENS do not. A persona's identity is currently its `peer_id`, which is a
random UUIDv4 minted per install — so Asha on the M5 and Asha on the 5090 are two
strangers who cannot recognise each other, and adding a machine to the grid adds
a duplicate of every persona on it.

`grid_identity::derive_citizen_id(owner, kind, name)` is the same primitive
generalised: UUIDv5 over `kind ‖ NUL ‖ owner ‖ NUL ‖ name`. Inputs are the
owner's login and the citizen's own name — no hostname, no username, no
per-install id, no clock. Every node derives the same answer offline. `kind`
(Persona / Agent) is part of the derivation so a persona and an agent session
sharing a name stay distinct, and the owner component keeps two different humans'
"asha" from ever colliding.

This is identity, NOT credential. The Ed25519 keypair in `airc-identity` stays
exactly where it is and keeps answering "may this process act as X?". Conflating
those two questions is how a per-install random `peer_id` came to stand in for a
persona's whole selfhood.

Pure function + tests only in this commit — the `persona_id == peer_id` invariant
in continuum is enforced with explicit guardrails in several places, so swapping
it over is its own reviewable change, not a drive-by. Tests pin: reproducible
from owner+name alone (the M5 and the 5090 agree with zero coordination), no
cross-owner collision, no cross-kind collision, NUL separators defeat boundary
smuggling, and a rename derives a new identity rather than silently re-pointing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…achines on it

Joel: "Ignore the entire grid as a concept."

He is right, and I had just re-introduced it: an hour after being told the model
is the ordinary one — one gh account, N machines, same rooms, same members — I
named the new module `grid_identity` and its enum `CitizenKind`. Inventing a
noun for something every messaging system already has is how this codebase grew
a private vocabulary that then needed private mechanisms to support it.

`grid_identity` -> `account_identity`, `derive_citizen_id` -> `derive_member_id`,
`CitizenKind` -> `MemberKind`, and the module docs now say what it plainly is:
one account, N machines, the same rooms and the same members on all of them. No
behavior change — same derivation, same five tests, all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…s a member with

The derivation was a pure function with no way to reach it: a consumer minting a
persona has a home path, not an owner login, and cannot resolve the owner until
after it attaches — by which point the peer_id is already minted. Chicken and egg,
and the egg is why continuum mints `Uuid::new_v4()` today.

`resolve_member_peer_id(home, kind, name)` closes it: open the machine-account
store for `home`, resolve the owner, derive. The caller hands the result to
`attach_as_with_peer_id`, so a persona named "asha" minted on ANY machine of the
account gets one identity instead of one per box.

Errors rather than falling back, same rule as the resolver underneath it: a member
minted under a guessed owner belongs to nobody's account and is invisible to every
other machine, which is worse than refusing to mint.

Deliberately untested here beyond the derivation's own five tests — the resolver
is a straight map over `mesh_identity::resolve`, and the honest test ("two homes
on ONE account derive the same member") needs a temp-rooted HOME to simulate a
shared machine account, which is a harness change, not an assertion. Naming that
rather than writing an environment-dependent test that passes because this box
happens to have gh auth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…iscovery to death

Measured live on the M5, 2026-08-04, in ONE daemon log:

    44,704  relay self-elections
     4,951  "gh request budget exceeded for Registry (30/30 of 30 in 60s)"
        52  enrolled peers, ZERO reachable
      ~4.5s refresh re-entry against a 60s REFRESH_INTERVAL

The mechanism is a closed cycle. `set_disconnect_observer` nudges `route_wake`
on every session drop (#240, so a real reconnect doesn't wait out 60s). A
refresh DIALS every stored peer. On a node whose registry is mostly dead peers,
those dials drop, each drop nudges again, and the next refresh starts ~4.5s
later — the length of the dial sweep. Nothing bounded it.

The damage isn't the CPU, it's the starvation: every pass spends GitHub
requests until the governor cuts the node off at 30/60s, and the account
registry is HOW PEERS FIND EACH OTHER. So the loop consumed the budget that
discovery needs, then kept hammering the closed door 44,704 times. It cannot
climb out on its own, which is why a restart "fixes" it for a minute and the
ghost returns a few times a day.

`MIN_REFRESH_SPACING` (15s) floors re-entry on the wake path only. The nudge is
kept — a genuine reconnect still beats the 60s interval by 4× — it simply cannot
re-enter faster than the floor, bounding the cycle at 4/min instead of
unbounded. The timer path is untouched (it already waits 60s).

Test pins the storm shape directly: 600 nudges across 60 virtual seconds must
produce at most `1 + 60/MIN_REFRESH_SPACING + 1` refreshes. Unfloored this test
counts hundreds — it fails on the exact defect measured above.

This bounds the spin. It does NOT yet make the gh governor's "retry in 60s"
actual backpressure (the loop still re-attempts inside the refused window) —
that is the sibling fix and it is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ry that cannot run

Lived 2026-08-04: a locally-built airc landed on PATH and was SIGKILLed by macOS
on every invocation — exit 137, zero output. `airc --version`, `airc status`,
`airc join` and the daemon each silently did nothing. Twenty minutes of a
completely dead mesh that read, from the outside, exactly like a transport bug.
One `codesign -s -` and it ran immediately.

(The corrupt signature was a hand-run `cp` straight over the installed path, not
this script — install.sh already does the correct cp-to-temp + atomic mv. The
gap this closes is different: the script printed "Installed airc" without ever
executing what it installed.)

"Installed" was a claim about a file existing. It is now a claim about a binary
that answered `--version`, and the reported line carries the version it actually
printed. The check is deliberately cause-agnostic — bad signature, missing dylib,
wrong-arch build all fail identically and all get caught. On macOS it attempts one
ad-hoc re-sign and re-verifies before giving up, so the common case self-heals
instead of stranding the operator.

A silently-dead airc is indistinguishable from a broken mesh, which is the most
expensive failure this project has: it sends you hunting the network for a defect
that is a file permission bit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply

Copy link
Copy Markdown
Contributor Author

Marked draft — do not merge.

Splitting this: the two verified, low-blast-radius fixes moved to #1317 (wake-path floor + install verify-the-deploy). What remains here is the identity work, and it is not ready:

  1. CI is red (cargo test on ubuntu + macos, clean-install on linux + macos) and I have not diagnosed it. My local "reproduce without gh" attempt was invalid — cargo is homebrew-installed, so the PATH I built to exclude gh still contained /opt/homebrew/bin. That experiment proved nothing.

  2. The expected cause is a real regression. Deleting the git config user.email and local:<host>:<user> fallbacks means any box that cannot reach gh now gets MeshIdentityError::Unresolved instead of an identity. That is correct for cross-machine convergence and wrong for a user who never signs into GitHub. It needs a product decision — does a gh-less machine run as a local-only account, or refuse? — not a code tweak.

  3. The member-derivation half is unwired. derive_member_id / resolve_member_peer_id are pure functions with tests and no production caller. The consumer change (continuum's mint_fresh_intent, which does Uuid::new_v4() then derives the name from the id) was deliberately not made, and the design behind it was wrong anyway: deterministic per-account naming would hand every downloader the same personas. Genesis wants to be a once-per-account event that syncs, not a per-machine mint.

Parked until the gh-less question is answered.

@joelteply
joelteply marked this pull request as draft August 4, 2026 07:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant