fix(identity): run the diverged-room heal on the daemon clock, not only on airc join - #1316
fix(identity): run the diverged-room heal on the daemon clock, not only on airc join#1316joelteply wants to merge 7 commits into
airc join#1316Conversation
…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
|
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:
Parked until the gh-less question is answered. |
The mechanism behind "airc is broken again"
Room UUIDs are
UUIDv5(mesh_identity ‖ NUL ‖ channel_name). The mesh identity is resolved locally:gh api user→git config user.email→ a fabricatedlocal:<host>:<user>.A machine that boots before
ghcan answer mints the provisional identity and derives every room UUID under it. Its#generalis 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.rsnames the live incident: bigmama's#generalderived underlocal:unknown-host:unknown-userwhile M5 derived under the gh login.The identity does self-heal (provisional sources re-probe
ghon every resolve). The state derived from it does not.What was already there, and what was missing
SubscriptionSet::rebind_divergedis the correct repair — written, documented, unit-tested. Its only callers arejoinandensure_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 manualairc 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.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
ghonce a minute, so reconvergence lands within ~60s ofghbecoming reachable, with nobody typing anything.Test
tests/identity_heal_reconcile.rspins the three things therebind_divergedunit test cannot:It also reproduces the divergence: joining under a pinned
local:...identity derives a demonstrably different room UUID than the healed one. Hermetic — pins anOperator-source identity, never shells out togh, 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