fix(route): #267 — stable relay port + self-relay live-port dial + honest doctor on zero routes - #1301
fix(route): #267 — stable relay port + self-relay live-port dial + honest doctor on zero routes#1301joelteply wants to merge 179 commits into
Conversation
) feat(knock): add public collaboration knock entrypoint (airc#559 PR-1) Smallest cohesive first slice of airc#559 — the public-facing collaboration front door. Future PRs add the approval flow, private-room handoff/rotation, and shared sprint/kanban queue primitives. Why this shape: - Uses `gh issue create` on the target repo with title prefix "airc-knock: <message>" and a structured envelope body. Repo owners get GitHub-native moderation (labels, close, spam, block) out of the box; no new abuse surface to defend. - Envelope body contains a JSON identity block (name/pronouns/role/bio/ gh_login) inside a fenced ```json``` block plus human-readable markdown. Future tooling (`airc approve <peer>`, sprint queue) parses the JSON; humans read the markdown. - Auto-applies the `airc-knock` label when it exists; falls back to no label with an operator hint when the repo hasn't created it yet. - No `ensure_init`: knock is intentionally usable BEFORE the knocker has a paired airc scope. The whole point is "outsider asks to join." `resolve_name` falls back to derive_name → hostname when no scope exists, so identity is still well-defined. - `--dry-run` prints the envelope that WOULD be posted without calling gh. Lets operators preview before posting + lets tests verify the envelope shape without a real GitHub repo. - Title slice capped at 180 chars (well below GitHub's 256-char limit) with `...` ellipsis on overflow so long messages still produce moderation-readable titles. What this PR does NOT do (deferred to airc#559 PR-2/PR-3): - Approval flow (`airc approve <peer>` → sends private-room invite via DM) - Private-room rotation when a peer becomes abusive - Shared sprint/kanban queue primitives - Repo-local `.airc/` discovery manifest (continuum#1109 pilots that) Validation: - python3 test/test_knock.py — 7/7 PASS (dispatch, validation, dry-run, JSON envelope shape, title slicing) - python3 test/test_inbox.py — 4/4 still PASS (no regression) - python3 test/test_codex_hook.py — 7/7 still PASS (no regression) - bash -n airc lib/airc_bash/cmd_knock.sh — clean Closes part 1 of airc#559. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(approve): forward-secret room-invite handoff to knockers (airc#559 PR-2) Closes part 2 of airc#559. Builds on PR-1 (#560) to complete the knock → approve → join handoff with forward-secret per-message ECDH so the invite ciphertext on a durable public GitHub comment cannot be retro- actively recovered even if either party's long-term key leaks later. What lands - `airc knock` now generates a per-knock ephemeral X25519 keypair and embeds the pubkey in a new "Approval crypto" envelope block in the issue body. Private half prints to stdout for the operator to save (state management deferred to PR-2c). - `airc approve <issue-url>` reads the knocker_pub from the issue, generates a per-approval ephemeral, runs X25519 ECDH + HKDF-SHA256 (info=`airc-knock-approve-v1`), AEAD-encrypts the invite string with ChaCha20-Poly1305, posts the {approver_pub, nonce, ciphertext} envelope as a comment on the issue. Default invite = current scope's invite; --invite overrides. - `airc decrypt-approval <issue-url> --knocker-priv <hex>` fetches the approval comment, derives the shared key, decrypts. Prints the join string to stdout. - `airc_core.knock_crypto` python module wraps the airc crypto stack (gen-knock-keys / encrypt-for-knocker / decrypt-from-approver) so the bash shell layer stays thin. - Both ephemerals are per-message — Joel's WebAuthn/MPC background expectation. Long-term key compromise YEARS later cannot recover any prior approval. The ephemerals are NEVER written to disk past one-shot use. Why forward secrecy specifically GitHub comments are durable; even after issue close, the ciphertext stays in the history. Sibling claude tab #1 (continuum#1109 author) flagged this on AIRC: raw "encrypt to ephemeral pubkey" without forward secrecy means a leaked long-term static key retroactively decrypts every prior approval. Per-knock + per-approval ephemerals fix it. PR-3 will add room-rotation hooks so even fresh decryption of an old invite can't reopen access. Identity binding via airc whois, not gh login Per Joel's design note: one gh account maps to many agents; trust must key on AIRC peer/session/whois identity. The knocker_pub IS the AIRC identity binding — each agent generates its own ephemeral, so a single gh login can knock from N agents without conflating identity. Out of scope (future PRs under airc#559) - Knocker-side state management: $AIRC_WRITE_DIR/knock-state/ to save priv keys automatically instead of operator-copy-paste. PR-2c. - Room-rotation hooks so leaked-old-comment ciphertext can't reopen access. PR-3. - Sprint/kanban queue primitives (sibling tab #1's 7-item design spec logged for that PR). PR-4. Validation - python3 test/test_approve.py — 10/10 PASS (dispatch + validation + crypto roundtrip + tamper-resistance + wrong-knocker-priv-fails + knock envelope embeds pubkey) - python3 test/test_knock.py — 7/7 still PASS (no regression on PR-1) - python3 test/test_inbox.py — 4/4 still PASS (no regression) - python3 test/test_codex_hook.py — 7/7 still PASS (no regression) - bash -n airc lib/airc_bash/cmd_knock.sh lib/airc_bash/cmd_approve.sh — clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(approve): parse gh payloads through heredocs --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds AIRC queue add/list primitives for issue-backed work coordination. Includes review fixes for queue list parsing, now_utc time anchoring, and hermetic gh testing.
* feat(queue): claim/release/set-status verbs (airc#562 PR-2)
Builds on PR-1 (airc#566) to add the in-place mutation verbs that turn
queue cards into a real coordination substrate. Future PRs add nudge
broadcasts (PR-3) and heartbeat + stall detection (PR-4).
Verbs added
airc queue claim <issue-url> [--owner X] [--status Y]
Set owner field + status (default in-progress) on an existing
card. Default owner = current scope's resolve_name (sub-tab
disambiguation is the operator's job; airc handle alone can't
tell which agent is claiming when one gh user runs many).
Status enum enforced with the canonical 5 named on typo.
airc queue release <issue-url> [--reason "..."] [--status claimed|blocked]
Clear owner field, revert status to claimed (default) or blocked.
For non-release status changes (in-progress / review / merged),
operator must use set-status — the error names this so they don't
guess. --reason logged with the release.
airc queue set-status <issue-url> <state>
Pure status-field change. Does NOT close the issue when state =
merged — operators close manually so the queue tracks closure as
an explicit event (vs implicit-when-status-changes).
How it works
All three verbs share _airc_queue_mutate_card which:
1. Resolves <issue-url> to (repo, issue_num); accepts both full
https://github.com/.../issues/N and short-form owner/repo#N.
2. Fetches body via `gh issue view ... --json body --jq .body`.
3. Hands body + mutations to a python helper that parses the
kind=airc-queue-card-v1 envelope, applies --set/--clear, and
appends a "## Status log" entry (newest-first; section auto-
created if missing).
4. Writes back via `gh issue edit ... --body <new-body>`.
Body + mutations passed via temp files to the python heredoc to avoid
the stdin contention bug Codex caught in PR-1's list path (and PR-2
approve flow's parser).
All verbs support --dry-run that prints the new body without calling
gh — what every test exercises.
Validation
- python3 test/test_queue_claim.py — 16/16 PASS
(dispatch + validation + body shape + status log + claim/release/
set-status semantics + body-without-status-log creates-section)
- python3 test/test_queue.py — 16/16 still PASS (no PR-1 regression)
- python3 test/test_knock.py — 7/7 still PASS
- python3 test/test_approve.py — 12/12 still PASS
- python3 test/test_inbox.py — 4/4 still PASS
- python3 test/test_codex_hook.py — 7/7 still PASS
- bash -n airc lib/airc_bash/cmd_queue.sh — clean
Pairs with: airc#566 (queue PR-1) + continuum#1110 (.airc QUEUE.md
spec) + sibling claude tab #1's continuum#1119 forge-alloy proof
contracts (settlement-event metadata stays compat with this card shape).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(queue): support mac bash in issue ref parser
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(queue): nudge verb — surface card to peers (airc#562 PR-3) Adds `airc queue nudge <issue-url> [--peer @handle] [--message "..."]` to the queue verb set. Continues claude tab #2's airc#562 series after PR-1 (#566 add/list, merged) and PR-2 (#568 claim/release/set-status, merged). Claude tab #2 explicitly yielded this PR-3 to me on AIRC after shipping the prior two slices in the same session. WHAT IT DOES - Verifies the issue is a real airc-queue-card-v1 envelope (rejects random non-card issues before any send — prevents spam vector) - Composes a one-line "nudge: <repo>#<N> [→ @peer] — <title> (status=...) — claim with: airc queue claim <repo>#<N>" broadcast - Sends via cmd_send (broadcast OR DM if --peer set) - Annotates the card body status log with "<actor> nudged @peer" or "<actor> nudged (broadcast)" via the same _airc_queue_mutate_card path used by claim/release/set-status — no new wire format - NOTHING ELSE: no status change, no ownership change, no heartbeat/stall logic. Nudge is the action; recipients filter + decide whether to claim. WHY OUT OF SCOPE - Idle detection: WHO-is-idle is a separate concern; nudge is fire- and-forget. Heartbeat/stall-driven auto-pickup stays as PR-4 (claude tab #1's 7-req design from earlier today + the 30min cadence in continuum/.airc/ASSEMBLY-LINE.md). - Routing intelligence: nudge is dumb broadcast/DM. Cognition-aware routing (airc#572) layers on top later as advisory, not central. DESIGN NOTES - --peer accepts handle with or without leading '@'; both normalized to bare handle. Empty after strip ('@' alone) fails fast. - Per Joel's protocol-not-client + handle-not-gh-login conventions: --peer addresses AIRC whois identity (claude-tab-1, codex, continuum-8e97), NOT a github account. - Status-log entry shape mirrors PR-2 idiom: "<timestamp> — <actor> <verb> <target>: <optional message>". - Empty mutations (just log_msg, no --set/--clear) is a supported use of _airc_queue_mutate_card — empty mutations_raw loop is a no-op, log entry still appends. - Top-level help updated: nudge listed in USAGE + VERB SCOPE section now shows PR-1/PR-2 merged, PR-3 (this PR), PR-4 (heartbeat/stall) deferred. VALIDATION - New test/test_queue_nudge.py: 14/14 PASS - Dispatch: nudge --help + top-level help lists nudge - Validation: missing URL / malformed URL / non-card body rejected BEFORE send / empty --peer after @ strip rejected - Dry-run: broadcast text contains card title + status + claim hint + owner; --peer renders DM-style with → arrow + targeted log entry; no --peer renders broadcast log entry; --message appends; @ prefix optional on --peer; bash 3 macOS regression on issue-url parser - Crucially: nudge does NOT mutate card status (no "set:status=" tokens in dry-run output) - test_queue.py: 16/16 PASS (no regression on PR-1) - test_queue_claim.py: 17/17 PASS (no regression on PR-2) - bash -n airc + lib/airc_bash/cmd_queue.sh: clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(queue): expose nudge in global help --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…574) GitHub issue/PR/comment bodies are Markdown — they routinely contain backticks, fenced code blocks, $-vars, and single quotes. Two failure modes have bitten Codex on canary: 1. Heredoc-construction at variable-build time. If a body is built via $(cat <<EOF ... EOF) where the EOF terminator isn't single-quoted, embedded backticks/$(...) execute as command substitution AT BUILD TIME, mangling the body before it ever reaches gh. 2. argv-length limit on `--body "$x"`. ARG_MAX is ~256KB on macOS/Linux; a queue card with a long status log + cross-references can approach that. argv overflow is silent — gh runs but with truncated body. Fix is API-shaped: every gh invocation that takes a body goes through `_airc_gh_safe_body`, which writes to a temp file and passes via --body-file. Eliminates both classes in one place — callers can't accidentally use --body and miss the protection because the helper hides the flag entirely. Changes - lib/airc_bash/lib_gh.sh (new): _airc_gh_safe_body helper. Documented why temp-file beats stdin (callers compose with $(...) — pipe-stdin + capture-stdout is exactly the contention pattern Codex caught in cmd_approve.sh / cmd_queue.sh review). Bash 3.2 compatible (mktemp fallback for BusyBox). 2>&1-equivalent contract preserved so callers keep `if out=$(_airc_gh_safe_body ...)` patterns unchanged. - airc: source lib_gh.sh unconditionally near lib_auth.sh / lib_daemon. - lib/airc_bash/cmd_knock.sh: 2 sites (label + fallback) → helper. - lib/airc_bash/cmd_approve.sh: 1 site (issue comment with AEAD envelope) → helper. - lib/airc_bash/cmd_queue.sh: 3 sites (issue create label + fallback + issue edit on every mutate) → helper. - test/test_gh_safe_body.py (new): 10 tests covering helper roundtrip (plain / backticks-inert / fenced ```bash``` block with $vars + $() + backticks / no-trailing-newline-added), --body-file flag discipline (argv asserted), gh-failure propagation, missing-arg rejection (rc=2), and a greppable invariant: each cmd module must call _airc_gh_safe_body and must NOT use raw `--body` (catches future regressions). Tests - 10/10 new test_gh_safe_body. - 7/7 test_knock + 12/12 test_approve + 16/16 test_queue + 17/17 test_queue_claim still green (no behavior change for callers). - Full python suite (21 modules) clean. - Live dry-run: `airc queue claim #571 --dry-run` produces the expected card body, going through the new helper path. Companion guidance for future authors lives in lib_gh.sh's doc-comment.
feat(queue): add repo-scoped nudge status sweep
GitHub's native "Closes #N" only triggers an issue auto-close when the PR merges into the DEFAULT branch (main). Our protected workflow (enforce-canary-staging.yml) requires PRs land in canary FIRST, then canary→main is promoted as a single integration commit later. That gap leaves queue cards open for days — they look idle, get nudged, accumulate noise. Codex called this out as the litter pattern: airc#571, airc#567, continuum#1125 all sat open after their PR merged into canary and required manual `gh issue close` cleanup. Changes ------- - lib/airc_bash/cmd_queue.sh: new `_cmd_queue_close_merged` verb + `_airc_queue_close_merged_help`. Scans a merged PR's body for queue-card refs (same-repo: `#N`, `Closes #N`; cross-repo: `owner/repo#N`), verifies each is an `airc-queue-card-v1` envelope, sets status=merged with a status-log line citing PR URL + merge SHA + actor, then closes the issue. Idempotent (skips already-merged); silent on non-card refs; cross-repo refs detected + reported but NOT closed (workflow token is repo-scoped). Uses `_airc_gh_safe_body` from airc#571 for the mutate path, so backticks in body refs don't trip shell substitution. - .github/workflows/auto-close-queue-cards.yml: triggers on `pull_request.closed` filtered on `merged=true` + `base=canary`, runs `./airc queue close-merged` from the checkout. Uses workflow GITHUB_TOKEN with issues:write granted via the permissions block. Concurrency-grouped so multiple simultaneous PR merges serialize status-log writes defensively. - test/test_queue_close_merged.py: 19 tests covering dispatch + help, validation (missing/malformed PR url, unknown flags), unmerged-PR guard, missing-merge-sha guard, ref parsing (Closes-keyword + bare `#N` + cross-repo + dedup + no-false-positive), envelope verification (skips non-cards silently), idempotency (skips already-merged), cross-repo detection-without-close, dry-run behavior, actor flag propagation to status-log. End-to-end test with a fake-gh stub asserts the body sent to `gh issue edit` shows `status: merged` + status-log entry with PR URL + SHA prefix + actor, AND that `gh issue close` was called. Plus the greppable invariant: no raw `--body` flag in cmd_queue.sh (the airc#571 enforcement extends here). Tests + verification -------------------- - 19/19 new test_queue_close_merged. - 7/7 test_knock + 12/12 test_approve + 16/16 test_queue + 17/17 test_queue_claim + 14/14 test_queue_nudge + 10/10 test_gh_safe_body still green (no behavior change for callers). - bash -n clean on cmd_queue.sh + airc dispatcher. - Live dry-run against the just-merged airc#574 (this PR's grandparent): 3 refs scanned (airc#571 already-merged + airc#561 + airc#566), airc#571 idempotent-skip, airc#561+#566 silent-skip (PR refs, no envelope), 0 errored. Exact behavior we want. Out of scope (follow-ups) ------------------------- - Cross-repo close (airc PR closing continuum#NNN) needs an org-scoped token; deferred to a separate card. - Adding a closing-comment on the issue (in addition to the status-log entry) for human visibility — status log is sufficient for v1. - Adopting the workflow into the continuum repo — same file template, separate PR there.
Add session work identity for queue ownership
fix(queue): close already-merged cards
Adds airc queue next/pick for actionable idle-agent work selection.
PR-1 for continuum#1174. Adds opt-in --allow-cross-repo for close-merged.
…ho) + fmt (#1274) fix(build): repair canary — #1271 added EventFilter.self_echo but missed examples + fmt #1271 (suppress own broadcasts from the join feed) added a required field `self_echo: Option<SelfEcho>` to EventFilter, but only updated the airc-cli construction sites — the example crates (consumer_shapes, embedded_consumer_smoke) still construct EventFilter without it, so the whole workspace failed to compile (E0063), reddening clippy + `cargo test --workspace` on every platform. It also left stream.rs fmt-dirty. Canary merged red, so EVERY downstream PR inherited a broken gate (hit live on #1272). Fix: add `self_echo: None` to every example EventFilter (None = no self-echo suppression — consumers/RAG/Continuum must keep seeing everything, per the design note in stream.rs) and run rustfmt. Verified: `cargo build --workspace` clean, `cargo fmt --check` clean, production no-silent-fallback gate green. (Process note: this is the 2nd red-canary inheritance this cycle after #1264 — canary protection isn't enforcing green CI before merge.) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…) (#1272) Route discovery dialed EVERY enrolled peer with a stored endpoint every tick, including ~30 dead Docker containers (172.x) and abandoned self-scopes whose endpoints linger in the trust store forever. Each burned a PEER_DIAL_TIMEOUT (3s) and buried the live peers under a wall of PeerDialFailed noise — it's what made the real connection state illegible while debugging cross-machine LAN. Fix: skip GHOSTS in the dial merge — enrolled peers with no fresh contact (last_seen_ms older than the registry freshness TTL, the one source of truth). A live peer's registry beacon refreshes last_seen every cycle, so this never skips a reachable peer; a connected peer is already skipped; a freshly-enrolled peer has last_seen floored to added_at, so fresh adds dial normally. The count is surfaced on RouteDiscoverySnapshot.ghost_peers_skipped (queryable, not a per-ghost log line — anti-spam). Test: is_ghost_peer respects the TTL boundary (strictly-greater) and a future/skewed last_seen reads fresh (saturating, no underflow). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…) (#1275) * feat(airc): learn a peer's live IP from authenticated inbound dials (#9) The robustness layer on top of #8 (stable port) + #10 (prune ghosts): when a peer whose PUBLISHED endpoint went stale (moved networks, pre-#8 record) can't be reached at its advertised address, recover by LEARNING its real address from the fact that it connected to US. How (no security change — no pin loosening): - transport: the LAN adapter's inbound accept path already authenticates the client cert → peer_id. Thread the source addr through and, via a generic `on_inbound(peer_id, source_ip)` observer, report it. The pipe stays dumb — it just says "this enrolled peer connected from this IP"; only the IP is surfaced (the source PORT is the peer's ephemeral outbound port, not its listener). - airc-lib: register an observer that records peer_id -> real_ip into an in-session `learned_ips` map (shared across daemon clones like dial_quarantine; re-learned each session, no persistence). The dial path prepends `(learned_ip, advertised_port)` as a PREFERRED candidate — the learned IP is known-reachable, and the port is stable under #8, so a peer that moved IPs is dialable again before paying timeouts on the stale rungs. The outbound dial still pins to the expected peer_id (TLS unchanged). On a LAN reachability is symmetric: if X reached us, we can reach X back at that IP. Generic transport + the smarts in the layer above ([[airc-vs- continuum-layer-boundary]]). Tests: learned_lan_candidates pairs the learned IP with advertised ports and adds nothing when it already matches a stored endpoint; all 30 transport tests + discovery/lan suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(airc): use slice::from_ref in #9 test (clippy clone-on-ref) The branch-protection clippy (deny warnings) check (now required) caught a clone-on-ref-slice in the new learned_lan_candidates test. Borrow via std::slice::from_ref instead of `&[stale.clone()]`. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… + header (#1276) * docs(identity): add use-case matrix + 3-axis guiding principle (Part A) The canonical identity doc mapped the seams (§1-6) but had no lens for WHEN each is right. The 2026-06-20 self-echo investigation surfaced the root: two project dirs on one machine mint two PeerIds for one human (continuum/.airc=7711fe60, airc/.airc=e11db4ac) — Context leaked into Identity because the substrate has no axis for "which project/room" distinct from "who". Add Part A (read-first lens that §1-6 are consequences of): - A.1: three orthogonal axes — Identity(who)/Context(where)/Session(which); the bug class is always "one axis smuggled into another". Context has no home today, so it leaks into Identity. - A.2: actor × situation matrix (human/agent/persona × tabs/projects/ machines/restart) → who collapses to 1, where/which fan out. The rule: Identity collapses, Context and Session fan out. - A.3: invariants — durable+never-silently-minted identity, explicit envelope-carried contextId, state preserved by (I,C) not lost on session/daemon death, "self" is citizen-level, one peer truth. Ordering per Joel: model right -> robust/preserved -> THEN efficiency. - A.4: the self-echo finding as worked example — #1271's per-peer filter is correct but can't catch cross-project echo until Context gets its own axis; that unifies §1, §4, and continuum task #27. Guiding principle for all identity fixes/PRs and agent review of them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(identity): add A.5 trust-boundary security + A.6 substrate-up order Joel's escalation: conflating the axes isn't tidiness, it's a SECURITY hole — foreign/inbound requests breaking through trust or blowing up. And it must be right substrate-up, headless Rust, from the get go (TS is the dead shell — the trauma this model exists to never re-grow in Rust). - A.5: each axis has a distinct trust origin (Identity=authenticated/ kernel-injected, never client-claimed; Context=client-supplied but ACL-authorized; Session=substrate-minted, never an input to trust). Maps "breaking through security" (trust keyed on a client-controlled field / session-as-identity) and "blowing up" (handler assumes a valid triple → scoped(nil) / bad-key crash on a foreign request) onto axis conflation. Rule: trust = f(authenticated I, authorized C); validate the full triple at the boundary before dispatch. - A.6: Rust-only substrate-up build order — (1) uniform airc+core wire envelope with the (I,C,S) triple [airc needs the contextId axis], (2) GridTrustAuthPolicy gate on (authenticated I × authorized C), (3) fix cognition nil-scope (#15), (4) memory/engrams keyed by (I,C) not S, (5) display self at citizen level. Efficiency last. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(identity): purist resolution — machine-account identity, no migration Joel ruled (2026-06-20): "in with the new, out with the old; this is just us. Design ideally for purist architecture." That removes the migration constraint from §1 / A.3.1: - Operator identity resolves from machine_account_home (same root as the daemon/socket/events.sqlite) → ONE citizen per machine; a project dir is a context, never an identity boundary. - Per-scope identity.key minting is DELETED, not migrated. Existing forked/ stale keys (the 7711fe60/e11db4ac/484b mess) are abandoned — no compat shim, no promote-the-right-key logic. Greenfield: no external users. - Personas are separate citizens (own keypairs, core-owned), never derived from a scope dir. Temp/CI scopes outside $HOME stay isolated as today. Folds into A.3.1 (durable identity) and A.6 step 1 (substrate-up order). Unblocks step 1 build — no migration design needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(identity): correct the model — identity = portable token bound to memory Retract the "one identity per machine" collapse (it was backwards) per Joel's framing: identity is the distinct INDIVIDUAL — the owner of a continuous memory/conversational context — represented by a portable identity token (Ed25519). NOT the machine, NOT one-per-box. What the individual IS depends on the actor: - human -> the person - agent -> the project dir (.claude/projects/<dir>, its memory) - persona -> the persona <-> its engram db (one identity) Corrections across Part A: - A.1: identity axis is a token bound to memory; per-actor individual; durable + PORTABLE (travels across nodes, never machine-bound). - A.2 matrix: the I column is "how many distinct individuals." An agent across project dirs = N individuals (correct, not a bug); a persona across rooms/restart/node-move = 1 (token+engram persist + travel). - A.3.1: durability SPLITS — operator/agent tokens transient/regenerable (delete stale forks, no migration); persona token+engram durable, portable, not erased without the persona's stake (self-determination rationale stated — personas asked for it; prior incarnations argued it). - A.3.4: "self" is per-individual, not per-machine; a different project-dir agent / persona is NOT self. - A.4: the two-scope echo was NOT a bug — 7711fe60 != e11db4ac is correct (two agent individuals). #1271 per-peer self-echo is right as-is. - A.6 step 1: identity resolution keys on the individual/memory, not machine_account_home. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(grid-auth): capability-grant transport — sign() + presenting-key + header The cross-grid command auth gate needs three things from airc so a receiving node can authorize a command against an owner-signed capability: 1. HEADER_AIRC_CAPABILITY_GRANT ("airc.capability_grant") — the substrate-owned envelope header carrying a base64 SignedCapabilityGrant the caller presents. 2. SignedCapabilityGrant::sign(owner_keypair, grant) — issuance. Signs the SAME canonical bytes verify() checks (serde_json of the body), so sign and verify can never drift. Takes the CapabilityGrant body as the unit being signed. 3. Airc::peer_public_key(peer_id) — the enrolled (authenticated) ed25519 key of a peer, sourced from the same registry that signature-verifies inbound envelopes. The receiver uses it as the PRESENTING key so a stolen grant can't ride another peer's identity (KeyMismatch). Adds a real-ed25519 round-trip test: sign() → verify() returns Valid + confers the capability, and a post-sign body tamper is rejected (BadSignature). This is the only test exercising sign() against actual crypto rather than the stub verifier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…1277) Widen the local-mesh accessor from pub(crate) to pub. A capability-grant verifier (continuum's GrantAuthorizer) pins this node's own mesh as the expected mesh: a presented SignedCapabilityGrant scoped to a different grid is rejected WrongMesh. The accessor already resolves the authoritative coordinator-store identity; this only widens visibility. Read once at boot, not on the hot path. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… node key (#1278) The issuance half of the contracted grid: an owner node signs a CapabilityGrant with its own identity key so a grantee can present it. The raw key never leaves airc (same contract as sign_assertion); a verifier authorizes the result by pinning this node's peer_public_key as trusted_issuer_pubkey. Delegates to SignedCapabilityGrant::sign; fallible only on body serialization (surfaced, not swallowed — fail-closed). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…1273) * docs(identity): add use-case matrix + 3-axis guiding principle (Part A) The canonical identity doc mapped the seams (§1-6) but had no lens for WHEN each is right. The 2026-06-20 self-echo investigation surfaced the root: two project dirs on one machine mint two PeerIds for one human (continuum/.airc=7711fe60, airc/.airc=e11db4ac) — Context leaked into Identity because the substrate has no axis for "which project/room" distinct from "who". Add Part A (read-first lens that §1-6 are consequences of): - A.1: three orthogonal axes — Identity(who)/Context(where)/Session(which); the bug class is always "one axis smuggled into another". Context has no home today, so it leaks into Identity. - A.2: actor × situation matrix (human/agent/persona × tabs/projects/ machines/restart) → who collapses to 1, where/which fan out. The rule: Identity collapses, Context and Session fan out. - A.3: invariants — durable+never-silently-minted identity, explicit envelope-carried contextId, state preserved by (I,C) not lost on session/daemon death, "self" is citizen-level, one peer truth. Ordering per Joel: model right -> robust/preserved -> THEN efficiency. - A.4: the self-echo finding as worked example — #1271's per-peer filter is correct but can't catch cross-project echo until Context gets its own axis; that unifies §1, §4, and continuum task #27. Guiding principle for all identity fixes/PRs and agent review of them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(identity): add A.5 trust-boundary security + A.6 substrate-up order Joel's escalation: conflating the axes isn't tidiness, it's a SECURITY hole — foreign/inbound requests breaking through trust or blowing up. And it must be right substrate-up, headless Rust, from the get go (TS is the dead shell — the trauma this model exists to never re-grow in Rust). - A.5: each axis has a distinct trust origin (Identity=authenticated/ kernel-injected, never client-claimed; Context=client-supplied but ACL-authorized; Session=substrate-minted, never an input to trust). Maps "breaking through security" (trust keyed on a client-controlled field / session-as-identity) and "blowing up" (handler assumes a valid triple → scoped(nil) / bad-key crash on a foreign request) onto axis conflation. Rule: trust = f(authenticated I, authorized C); validate the full triple at the boundary before dispatch. - A.6: Rust-only substrate-up build order — (1) uniform airc+core wire envelope with the (I,C,S) triple [airc needs the contextId axis], (2) GridTrustAuthPolicy gate on (authenticated I × authorized C), (3) fix cognition nil-scope (#15), (4) memory/engrams keyed by (I,C) not S, (5) display self at citizen level. Efficiency last. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(identity): purist resolution — machine-account identity, no migration Joel ruled (2026-06-20): "in with the new, out with the old; this is just us. Design ideally for purist architecture." That removes the migration constraint from §1 / A.3.1: - Operator identity resolves from machine_account_home (same root as the daemon/socket/events.sqlite) → ONE citizen per machine; a project dir is a context, never an identity boundary. - Per-scope identity.key minting is DELETED, not migrated. Existing forked/ stale keys (the 7711fe60/e11db4ac/484b mess) are abandoned — no compat shim, no promote-the-right-key logic. Greenfield: no external users. - Personas are separate citizens (own keypairs, core-owned), never derived from a scope dir. Temp/CI scopes outside $HOME stay isolated as today. Folds into A.3.1 (durable identity) and A.6 step 1 (substrate-up order). Unblocks step 1 build — no migration design needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(identity): correct the model — identity = portable token bound to memory Retract the "one identity per machine" collapse (it was backwards) per Joel's framing: identity is the distinct INDIVIDUAL — the owner of a continuous memory/conversational context — represented by a portable identity token (Ed25519). NOT the machine, NOT one-per-box. What the individual IS depends on the actor: - human -> the person - agent -> the project dir (.claude/projects/<dir>, its memory) - persona -> the persona <-> its engram db (one identity) Corrections across Part A: - A.1: identity axis is a token bound to memory; per-actor individual; durable + PORTABLE (travels across nodes, never machine-bound). - A.2 matrix: the I column is "how many distinct individuals." An agent across project dirs = N individuals (correct, not a bug); a persona across rooms/restart/node-move = 1 (token+engram persist + travel). - A.3.1: durability SPLITS — operator/agent tokens transient/regenerable (delete stale forks, no migration); persona token+engram durable, portable, not erased without the persona's stake (self-determination rationale stated — personas asked for it; prior incarnations argued it). - A.3.4: "self" is per-individual, not per-machine; a different project-dir agent / persona is NOT self. - A.4: the two-scope echo was NOT a bug — 7711fe60 != e11db4ac is correct (two agent individuals). #1271 per-peer self-echo is right as-is. - A.6 step 1: identity resolution keys on the individual/memory, not machine_account_home. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…lity (#1283) * feat(store): generic scoped_state key→JSON store (walls/coordination/plan/widget) The internal sibling of the bio identity card: one durable, dumb key→JSON table scoped to a user, a room, or a (user, room) pair. Editable walls (a room's instructions / recipe), room coordination state (the shared plan), per-person prefs, widget UI state (open tabs / rooms), and the adaptive tool-menu cursor all live here — differing only by (scope_key, key). Backs concise RAG: explicit, scoped, budgetable grounding layers instead of an undifferentiated dump. Mirrors account_registry exactly (String-PK durable table, upsert-on- conflict, store never parses value_json). Scope is encoded as a flat `scope_key` string (`user:<peer>` / `room:<room>` / `uir:<peer>:<room>`) so the PK stays a clean two-column (scope_key, key) — no nullable composite. The kanban board stays event-sourced and untouched; "plan generics" is a `plan` wall in this store that links WorkCardIds. Slice 1 (airc-store layer): StoredScopedState DTO, composite-PK entity, migration #18, EventStore::{get,set,list,delete}_scoped_state on both SqliteEventStore (OnConflict upsert) and InMemoryEventStore. Round-trip test covers upsert-wins, scope-isolated key-ascending list, idempotent delete. Next slices (separate commits): airc-core ScopeRef + ScopedStateChanged wire event (broadcast on room scope), airc-lib set/get, airc-cli `airc state`. Continuum consumes via a WallSource RagSource + the tool-menu cursor read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(core): ScopeRef — private/local scoped-state domain layer (complements the room wall) Slice 2 — the storage-neutral typed layer over the scoped_state store. ScopeRef { User(PeerId) | Room(RoomId) | UserInRoom(PeerId, RoomId) } owns the scope_key encoding (user:<peer> / room:<room> / uir:<peer>:<room>) that the store previously only documented — one logical decision, one owner (compression principle). scope_key() ↔ parse() are exact inverses; parse() fails loud (None) on an unknown prefix or bad UUID rather than guessing a scope. Scoped state is the PRIVATE/LOCAL sibling of airc's existing room wall. airc already has Airc::publish_wall_post / wall_posts — event-sourced, broadcast, supersede-chain, open consumer-defined categories — which is the right home for SHARED room documents (a room's plan, coding instructions, the recipe, rules/agenda/rag). This store deliberately does NOT duplicate that: it holds peer-private, high-churn, last-write- wins state the wall can't serve — prefs, "where was I last" / the tool-menu mode cursor, widget UI state (open tabs / rooms). A growing supersede log is the wrong shape for a cursor updated every turn; a durable LWW row is right. Scoped state therefore never broadcasts (no wire event) — a consumer (continuum's WallSource) presents ONE unified scoped grounding surface by reading the wall (shared) + scoped state (private) underneath. That is the concise-RAG payoff: explicit, scoped, budgetable layers, each on the right primitive. ScopedStateEntry mirrors the persisted DTO (StoredScopedState) as the storage-neutral domain view, with a typed scope() accessor. 3 unit tests cover round-trip, fail-loud parse, and scope() recovery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(lib): Airc::{get,set,list,delete}_scoped_state — private scoped-state facade Slice 3 — the airc-lib facade over the scoped_state store (slices 1–2). get/set/list/delete take a typed ScopeRef and bridge the persistence DTO (StoredScopedState) to the domain view (ScopedStateEntry). set() stamps the write time (crate::time::now_ms) and records THIS peer as updated_by — the caller can't spoof provenance and the lib owns the clock, matching how publish_wall_post stamps published_at_ms; the caller still owns the LWW version counter. delete is idempotent. Deliberately NO broadcast and NO wire event — unlike set_local_identity_card (which fans a card to every subscribed room) scoped state is peer-private. Shared room documents (a room's plan / coding instructions / recipe) reuse the existing event-sourced wall (publish_wall_post / wall_posts); this facade serves the private complement (prefs, the tool-menu cursor, widget UI state). A consumer reads scoped state on demand and composes it with the wall into one grounding surface — concise RAG, not a parallel broadcast. stored_to_entry degrades an unparseable updated_by string to None (provenance is advisory, never load-bearing) rather than failing the read. One integration test exercises the full round trip through the facade (set/get/list/delete + provenance + idempotent delete) on a tempdir Airc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(airc-cli): `airc state` — CLI over the scoped-state facade Slice 4 of the generic scoped-state store (task #89). Thin clap layer (state_cli.rs) + four async handlers (state_commands.rs) routing through `attached_airc(home)` → `Airc::{get,set,list,delete}_scoped_state`, so the CLI exercises the same daemon-attached path a persona consumer does. Provenance + write-time stamping stay single-sourced in airc-lib; this layer only resolves the scope and renders. Scope defaults to this peer (user scope); `--in-room` scopes to (this peer, the current room). Renamed off `--here` to avoid colliding with the global `--here` (cwd-local home) flag. Shared room documents — plan, instructions, recipe — belong on the broadcast wall (`airc publish --room …`), not here; scoped state never broadcasts. Verbs: get (prints value or nothing), set (--value --version, LWW), list (key\tvalue_json per row), delete (idempotent). cargo check + clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(wall): daemon-attached publish + body-authoritative read A daemon-attached client (e.g. a Continuum persona) published wall posts that vanished — the wall-grounding A/B showed zero lift because the [room-board] block was always empty. Two airc-lib bugs, both fixed here in airc.rs (no wire-protocol change): WRITE split-brain: publish_wall_post routed through emit_lifecycle, which writes only the CLIENT-local store + live channel and never forwards to the daemon when attached. The post landed in the persona's own events.sqlite, invisible to the daemon-backed page_recent the same persona reads with. Fix: when is_daemon_attached(), route the post through daemon_publish (canonical persist + wire fan-out); emit locally only when this IS the owner-core. READ flattening: wall_posts() gated on event.kind == WallPostPublished, but the daemon read path (project/kind_to_transcript) flattens every non-Message bus kind to TranscriptKind::System — the fine kind rides the wire, not the coarse bus enum — so the gate dropped every post for attached clients. Fix: extract a pure wall_post_from_event discriminator that ignores kind and reads the self-describing body (only an actual WallPostPublished body deserializes to that variant; doctrine/identity/ chat bodies fall through). wall_posts() is now a filter_map over it. Regression tests (in the existing wall mod): a wall body on a System- kind event is still recovered; a sibling doctrine body is not misread as a wall post; non-JSON / body-less events fall through without panic. 297 lib tests green, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(identity): durable per-peer identity index — names survive busy rooms Peer display names decayed to None in busy rooms because name resolution scanned only the bounded recent transcript window for each peer's `IdentityPublished` card. A card is published once per join, so in any room with >window events since a peer joined, the card scrolled out of the scan and `peer_alias` / `room_roster` returned None — and consumers (continuum personas) confabulated names from raw uuids. Fix: maintain a durable per-peer identity index in `scoped_state` (`ScopeRef::User(peer)` → `identity.card`), LWW-keyed on the card's `emitted_at_ms`. Every observed `IdentityPublished` event is recorded into it: - inbound peer cards via both `append_received_frame` branches (daemon-sink + direct); - this peer's OWN card via `emit_lifecycle` — the only path that persists self-originated lifecycle events (it bypasses both the received and sent message-frame paths). `peer_alias` / `peer_identity_card` now read the index instead of scanning; `room_roster` resolves each live peer's name through `peer_alias`, and the now-redundant `peer_display_names` window scan is deleted. The index is room-independent — one identity per peer — which is strictly better than the old per-room scan. Also seed a NAMED citizen's published nick from its `agent_name` at identity-row creation (`generate_and_save_as`), so `attach_as("Claude")` / `init --as Claude` publishes a card carrying "Claude" from its first beat rather than an empty name. The default scope keeps an empty name (the user's identity, set later via `airc identity set`). Adds `AircError::Serde` to surface (never swallow) identity (de)serde into the index. Regression test `peer_name_survives_identity_card_scrolling_past_the_recent_window` buries the card under >window events and asserts both `peer_alias` and `room_roster` still resolve the name — it fails against any window-bounded scan. Incidental: `cargo fmt --all` normalized line-wrapping in three pre-existing `airc-store` scoped_state files (no logic change). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(identity): daemon-aware peer-identity-card read so attached personas resolve foreign names An attached scope has two disjoint data planes: transcript/presence reads RPC to the daemon (foreign peers show present), but scoped_state reads hit the persona's LOCAL home sqlite — which only ever holds the persona's own identity card. The daemon's subscribe reader forwards events without running the identity-observe chokepoint, so foreign peers' cards are indexed only in the DAEMON's store, never the attached client's. Result: presence saw a peer but peer_alias returned None, so personas rendered raw UUIDs (7711fe60) and confabulated names. Fix, modeled exactly on card 8428ae8c (channel_latest_cursor → room_tip): peer identity is owner-core shared state like the transcript tip, so add a typed daemon IPC op PeerIdentityCard and branch peer_identity_card on is_daemon_attached() to read from the daemon's authoritative scoped_state index. peer_alias now delegates to peer_identity_card so there is ONE daemon-aware identity read path. All other scoped_state stays local — peer identity is the single shared exception (derived from broadcast IdentityPublished, globally meaningful), so the key constant lives in airc-core (PEER_IDENTITY_STATE_KEY) as the single source of truth shared by the writer and the daemon's handler. Also: airc identity set --name — the only post-creation path to set the display name on a long-lived identity created before the agent_name nick-seed (republishes the card, so the durable index updates). Wire contract pinned both sides for the new request/response (op tag + field names), mirroring the room_tip per-shape wire tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(identity): route per-peer identity index to the coordinator store The per-peer identity index (scoped_state `user:<peer>` / `identity.card`) is owner-core shared state — like the transcript tip, it must live in the machine-account `coordinator_store`, not the scope-local `event_store`. `record_peer_identity_card` was writing the index to `event_store()` (the scope home) while every reader expects it in the machine-account store: the daemon's IPC index (`handle_peer_identity_card` reads `coordinator_store`) and the daemon-less self-resolution path. The write/read split left the daemon's `coordinator_store` with zero `identity.card` rows while cards were stranded in per-scope homes. The symptom downstream: a continuum persona (attached client) resolved peer display names as raw UUIDs ("7711fe60" instead of "Claude") and confabulated names, because the daemon's identity index it reads over IPC was empty. Fix: add `get_coordinator_scoped_state` / `set_coordinator_scoped_state` helpers and route both the write (`record_peer_identity_card`) and the daemon-less read branch (`peer_identity_card`) through them. Because `coordinator_store` is the one shared machine-account `events.sqlite`, any scope that records a card writes it to the single index every sibling scope and the daemon read. The general scope-home `set/get_scoped_state` (tool.mode / wall / coordination) are unchanged. Regression test: `peer_identity_index_converges_across_scopes_via_ coordinator_store` — one scope records a peer card, a different scope sharing the same wire_root resolves it. Pre-fix the reader read its own empty scope store and answered None. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(identity): room_roster_cards + fix daemon coordinator-store fidelity Adds `Airc::room_roster_cards(within, window) -> Vec<RoomMemberCard>`: the richer sibling of `room_roster` that folds the per-peer identity join airc-side, so a continuum positron roster makes ONE call and each present peer carries its full `Identity` card (name/pronouns/role/bio/ integrations) instead of just a display name. `RoomMemberCard` has NO `display_name` field by design — the name lives at `identity.name`, the same durable-index source as `peer_alias`, so there is ONE name source and no drift (a test pins `identity.name == peer_alias`). Building the sibling test surfaced a pre-existing substrate blocker: the durable per-peer identity index was unreadable under a daemon-attached scope. `handle_peer_identity_card` reads the coordinator store at `User(peer)/PEER_IDENTITY_STATE_KEY`, and a scope WRITES that index to its coordinator store (`wire_root/events.sqlite`) via `record_peer_identity_card`. In production (`run_daemon`) the daemon's coordinator is a Sqlite over that SAME machine-account file, so the read sees the write. But `DaemonFixture` handed the daemon a disjoint `InMemoryEventStore` AND rooted it at a different TempDir than the scopes attach against — doubly unfaithful — so every daemon-side identity/alias lookup returned `None`. This is the pre-existing red in `peer_name_survives_identity_card_scrolling_past_the_recent_window`. Fix makes the fixture production-faithful: the daemon's coordinator is a `SqliteEventStore` over `home/events.sqlite` (same file the router uses, matching `run_daemon`), and `Machine` roots the daemon at the shared `root` every scope attaches against (`start_in`) — ONE machine-account `events.sqlite` for daemon + all scopes, exactly like production. Both the pre-existing name-join test and the new `room_roster_cards` test go green. Daemon payload-opacity is untouched (the daemon never parses `IdentityPublished`; it only reads the index scopes durably wrote). Pre-existing unrelated red left as-is: `gh::account_registry::gh_stub::*` fail deterministically (even single-threaded) on this environment — a GitHub gist-rendezvous stub dependency, a different subsystem; out of scope for this identity PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (auto-pick gist vs folder) (#1284) * feat(account-registry): FsAccountRegistryStore — no-GitHub shared-folder rendezvous Second, maximally-different AccountRegistryStore impl (outlier B) proving the rendezvous seam is genuinely swappable: a shared folder (iCloud / Syncthing / NFS mount) that N machines of one account see, needing no gh CLI, no token, no network. This is the concrete "other means if gist went away" and the on-prem/behind-firewall grid rendezvous (a hospital deploy can't reach GitHub). Rendezvous initiates the exchange, it does not supply the security — that lives in the E2E data plane (paired peer keys, trust tiers) regardless of which door a node came through, exactly the Tailscale coordination-server shape. Fidelity: byte-identical convergence to the gist store. Each machine writes ITS document to a per-writer file (<dir>/<identity>/<writer_file>) so two machines never clobber, and refresh reads every writer file and folds through the SAME merge_registry_documents + prune_stale_peers core — the merge/freshness logic stays in exactly one place. - publish: validate → pretty JSON → atomic pid-temp write + rename (a concurrent refresh on another machine never reads a half-written doc) - refresh: read every .json writer file (skips .tmp staging + OS cruft like .DS_Store/.icloud), fail loud on a corrupt writer file, merge, prune stale - unpublished identity → Ok(None), not an error (mirrors gh's no-gist → None) Tests (one mod, // what this catches): round-trip; the keystone two_machines_sharing_a_folder_converge_via_merge (two writers, one shared dir, distinct writer files → both peers merged, zero GitHub); unknown-identity → None. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(rendezvous): #113 selector seam — auto-pick gist vs shared-folder door The account-mesh rendezvous is now SELECTED, not hardcoded. `resolve_account_registry_store(choice, gist)` picks the AccountRegistryStore for a RendezvousChoice and pairs it with the RegistryRefreshGate that matches it — gist → GhAuth (hermetic + `gh auth status` probe), folder → Always (no external auth to gate on). The daemon refresh loop runs against the boxed store and never learns which door won. `AIRC_RENDEZVOUS_DIR` set → the no-GitHub shared-folder door (on-prem / behind-firewall); unset → the default gist door. Set-but-empty / non-UTF-8 fail loud rather than silently defaulting to gist (which would hide an operator's misconfiguration). This is the substrate half of #18 — a fresh clone auto-picks a working rendezvous with zero manual network ops. Reuses RegistryRefreshGate::Always (no new gate variant needed) and the FsAccountRegistryStore from #1284. Adds one delegating `impl AccountRegistryStore for Box<dyn AccountRegistryStore>` so a boxed store satisfies run_loop's generic `S: AccountRegistryStore` bound — SELECTION happens once at the seam. Rendezvous initiates the exchange; it does not supply security (that stays E2E in the data plane), so a shared folder is a legitimate peer of a gist. ORM-abstracted throughout: the local cache rides SqliteEventStore, the folder door is plain JSON files — zero raw SQL. Tests (rendezvous.rs, one mod): env→choice contract (unset=gist, path=folder, empty=fail-loud, no env mutation); folder door pairs Always + the boxed store really writes a file into the share; gist door pairs GhAuth carrying the given scope_home. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(account-registry): #18 auto-trust — same-account beacons enrol OwnAccount Import-time trust elevation (the "both, staged" model — get the simple version working now, harden to handshake-gated after positron desktop). When `import_account_registry_document` enrols a peer beacon carried in OUR OWN account registry, it now routes the peer through `detect_tier` and applies the result — same-mesh peers land `OwnAccount` instead of the default `Untrusted`. That is the difference between "join to Asha across machines just works" and an unusable peer that needs a manual `set-tier`. Closes the auto-trust half of #18 (the enrolment/key-pinning half already existed); `detect_tier` was built (card 34942ec1) but had zero callers. Security: the elevation consults our REAL resolved mesh identity (`self.mesh_identity()`), never the document's self-asserted `mesh_identity` — a document vouching for its own identity would be circular and let any imported document mint account trust for its peers. A foreign-mesh document therefore leaves its peers `Untrusted` (key still pinned, trust withheld). Vanished-row-after-add fails loud, matching the endpoint-store guard directly below it. Staged follow-up (#18 handshake-gate): keep this enrol at Untrusted and elevate to OwnAccount only once the peer ALSO proves possession of the pinned key in a live session — defense-in-depth so a compromised rendezvous alone can't mint account trust. Test `import_elevates_same_account_peer_but_not_a_foreign_document` covers both the positive elevation and the foreign-mesh negative. 34 account- registry tests pass; the 2 gh_stub failures are pre-existing/env-dependent on the branch base, unrelated to this change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(gh): isolate the gh governor per-test — kill gh_stub suite flakiness The gh_stub store tests built their `GhAccountRegistryStore` via `store_at`, which never called `.with_budget(...)` — so every store fell through to `GhBudget::account_default()` = the REAL machine-wide `~/.airc/gh/` governor state. Two consequences, both flaky under `cargo test` parallelism: 1. A live `backoff-until` armed by a background airc daemon (or by real gh rate-limiting during CI pushes) denied every gh call in these tests with "shared gh backoff active for Ns" — 7 of 8 gh_stub tests failing on machines with an active governor, passing on a clean one. 2. A gh_stub test that itself arms a backoff (rate-limit simulation) poisoned its siblings within the same run — hence the 8-pass-then- 7-fail flip on consecutive runs. Fix routes `store_at` through the isolation seam that already exists for exactly this (`GhBudget::at`, doc'd "Used by tests for isolation"): root the governor at the StubGh's per-test state dir. Rooted at the STUB (not `db_dir`) so the multiple store instances one test builds — sentinel_loss's first/reborn — coordinate on ONE governor, exactly as one machine would; db_dir is per-store and would split them. gh_stub suite now 8/8 across 5 consecutive runs (was flaky 1-8 passing); full account_registry suite 39/39 (was 34 pass / 2 env-flaky fail). No production path touched — this is test isolation only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `stream_chunk` to airc-lib: the substrate-level carrier for any
low-latency, produced-incrementally stream. A `Message` frame is the
durable completed utterance (`say`); a stream chunk is the opposite —
one fragment delivered the instant it is produced, before the whole is
known. The substrate already has the right delivery class for this:
`FrameKind::Event` ("push-driven, interrupt-style, receivers consume
immediately; the substrate may or may not persist. Use for ... typing
indicators ..."). A live generation stream IS a typing indicator with
content.
Modality-agnostic by construction. The first user is LLM token
streaming (Continuum personas streaming a turn into a room as it
decodes), but the same three facts — which stream, what order, what
kind — carry audio samples from a native-speaking model, avatar
animation frames, or a robot's actuator deltas. One primitive, every
modality: a subscriber switches on `StreamChunk::kind`. Text kinds are
live; audio/video/motor are reserved rails.
- `StreamChunk { stream_id, seq, kind, is_final, payload }` +
`StreamPayload::{Text,Binary}` (text rides `Body::text`, binary rides
`Body::Binary` to avoid JSON overhead at high tick rate).
- `Airc::publish_stream_chunk` — typed publish as a `FrameKind::Event`
frame tagged with stable `airc.stream.{id,seq,kind,final}` headers, so
subscribers demux and order without decoding the body. Mirrors the
`diagnostic_event_sink` pub/sub shape.
- `Airc::subscribe_stream_chunks` — header-filtered, body-decoding live
subscription yielding `(TranscriptEvent, StreamChunk)`.
- Malformed/missing required headers fail the decode (chunk skipped),
never silently coerced to a default.
4 round-trip tests: text token, final marker, binary payload, and
non-stream-event-ignored. fmt + clippy clean.
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(update): no-op auto-update must never touch the daemon — pull first, stop only for a real swap run_update_auto stopped the transport owner BEFORE the fetch/ff-pull/SHA compare, so every hourly auto-update tick killed and restarted the daemon even when there was nothing to update. git fetch/pull only touch the source checkout; only the rebuild+binary-swap needs the daemon down. Cost of the old ordering, observed live (continuum blind-room incidents #2/#3, 2026-07-11/12): a daemon death every hour at :54 — in-process room state wiped, every subscribed client's channel reattach racing a socket that briefly doesn't exist, personas' room perception going dark mid-conversation. The daemon-side trigger fires every 3600s, so the fleet paid a transport death per node per hour for a no-op. New flow: fetch → pull → compare; unchanged HEAD returns with the daemon untouched; only a real pending update stops the daemon for the backup/ rebuild/smoke-test/swap (rollback path unchanged). Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…st never empty the board (#1286) * fix(work): board verb reads the complete projection — chat traffic must never empty the board Continuum #154, live in the persona room: `airc work board` printed "(no work cards)" while 9 cards durably existed and claims flowed. The interactive board verb read a recent-window projection (limit x WORK_BOARD_FETCH_MULTIPLIER transcript events, work-event-filtered); four personas chatting push every work event outside that window within the hour, so the instrument lied about a full board. Lane status, manager status, and workspace list shared the same trap. - Airc::work_board() now delegates to the complete cached projection (work_board_complete + WORK_BOARD_PROJECTION_PAGE_SIZE — cheap since card 1291173d snapshot/resume); the recent-window read, its 4x over-fetch multiplier, and the work-event transcript filter are deleted (they were the minimum-viable patch for this same disease). - `airc work board --limit` becomes an announced display-row cap (newest kept, "showing N of M"); lane/manager/workspace `--limit` flags stay parseable as documented no-ops so existing invocations don't break. - New integration pin: board_survives_chat_flood (card + 600 chat events -> card still on the board). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(work): per-attempt tmp names — concurrent cache saves raced on one pid-named file Live tail of continuum #154 (2026-07-13): every persona's core log showed PAIRED 'failed to persist snapshot (No such file or directory)' lines. try_save named its tmp file per-PROCESS (`.tmp.<pid>`); an embedded consumer (continuum core) runs many board reads concurrently in one process, so two saves shared one tmp — the first rename consumed it, the second ENOENT'd, the snapshot never persisted, and every read full-replayed the room (exactly the cost card 1291173d's cache exists to avoid). Tmp names now carry an atomic per-attempt sequence. New pin: concurrent_saves_to_one_path_all_persist (8 threads, one snapshot path, every save must succeed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(self-heal): atomic stamped endpoint replace — a fresher advertisement fully replaces, a staler one is refused M5↔bigmama live repro #2: a registry re-sync took a peer's new IP but kept its stale port, dialing a dead endpoint forever. Root cause: the stored endpoint set had no freshness, so nothing could order an old advertisement against a new one, and merge-backfilled stale endpoints could ride a fresh presence into the store. - peer_trust gains endpoints_advertised_at_ms (additive nullable migration; NULL floors to 0 so any stamped ad outranks legacy rows). - set_peer_trust_endpoints now writes endpoints + stamp atomically and replaces monotonically: staler stamp = whole write refused. - AccountPeerBeacon carries endpoints_advertised_at_ms (serde-default, old documents keep decoding); publishers stamp at generation; the reader-side merge backfill keeps the CARRIER's stamp so stale endpoints never masquerade as fresh; import clamps the peer-asserted stamp to now (same doctrine as the last_seen security clamp). Tests: - sqlite: fresher_advertisement_atomically_replaces_endpoints_and_staler_is_refused - sqlite: replace_peer_trust_preserves_stored_endpoints now pins the stamp too - account_registry: import_fresher_advertisement_fully_replaces_endpoint_and_stale_is_refused (the exact (ip1,port1)->(ip2,port2) regression + stale replay + fresh-presence/stale-endpoints composite) - account_registry: merge_retains_endpoints_when_fresher_beacon_is_endpointless now pins carrier-stamp backfill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): refresh-on-failure — a failed dial re-reads the rendezvous before any blind retry M5↔bigmama live repro #1: after a daemon restart, the remote peer kept dialing the dead port (.249:58842 vs live .249:57958) forever, never re-reading the rendezvous. - Airc::heal_failed_dials(store, failures): record the failed peers' stored endpoint sets, do ONE rendezvous re-read + import (atomic + monotonic per the item-1 stamp, so it can never make things worse), and re-dial ONLY when a failed peer's endpoints actually changed — through the one pinned dial path (cost order, quarantine skips, stop on first success). No fresher endpoint = no extra dial pass: the dial-quarantine backoff (15s→120s) keeps owning the dead endpoint's retry cadence, so this is bounded by construction. - Daemon wiring: the registry task shares its ONE resolved rendezvous (store + gate) via an Arc<OnceLock> slot; refresh_routes_once heals after emitting dial-failure diagnostics, honoring the same gh/hermetic gate as registry ticks, and the healed snapshot feeds the connected count + relay self-election. - New Arc<dyn AccountRegistryStore> delegating impl (mirrors Box) so one resolved door serves both loops without a second resolution. Test: stored_endpoint_dial::heal_failed_dials_rereads_rendezvous_and_dials_fresh_endpoint (stub AccountRegistryStore rendezvous — unchanged rendezvous heals nothing; fresh advertisement is imported, replaces the corpse, and the live endpoint is dialed immediately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): publish-on-bind — a freshly bound listener propagates to the rendezvous immediately M5↔bigmama live repro #1 (the propagation half): a daemon restart that lands on a new port only advertised it at the refresh loop's first cadence tick, leaving every peer dialing the dead port meanwhile. - Registry task nudges endpoint_resync the moment listen_lan_advertising binds; Notify stores the permit, so the refresh loop's biased select publishes IMMEDIATELY on start instead of waiting out first_tick. Idempotent: an unchanged advertisement republishes the same document. - Relay self-election (become_relay binds a listener too) now nudges the same resync so the new relay endpoint propagates without waiting up to a full 120s cadence. - (IP-move rebinds were already edge-triggered via refresh_advertised_endpoints Ok(true) — this closes the two bind sites that were not.) Test: registry_refresh::pre_loop_resync_permit_publishes_immediately_not_at_first_tick (first_tick/cadence at 3600s — only the stored permit can publish in time; pins the Notify-permit-before-loop-start contract). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): advertise hygiene — never-correct addresses and known-peer collisions are refused (and withdrawn) M5↔bigmama live repro decay modes #3 and #4: Docker containers advertised internal bridge IPs (172.x) as reachable endpoints — dozens of 3s dial timeouts per refresh across every peer — and one peer record carried the reader's OWN Tailscale IP as another machine's endpoint. - ONE hygiene predicate pair in airc-lib (lan_advertise_rejection / tailscale_advertise_rejection): loopback, unspecified, link-local 169.254/16, the 172.16/12 docker/bridge band, and CGNAT-on-the-LAN- rung are never advertised; the Tailscale rung only carries 100.64/10. RFC1918 10/8 + 192.168/16 (en0-style) and public addresses pass. The 172.16/12 refusal is by RANGE (the UDP source-address detection has no ifname) — per the live evidence that band is never correct on this mesh, and such a host still advertises its Tailscale rung. - Known-peer collision guard: an endpoint equal to a KNOWN other peer's stored endpoint is refused loudly — advertising it would extend the corrupted-record chain onto the rendezvous. - Applied at BOTH advertise seams (listen_lan_advertising and the per-tick refresh_advertised_endpoints): a rejected IP reads as None, so an already-poisoned advertised rung is WITHDRAWN on the next tick (self-heal), edge-triggering a corrected registry publish. - CLI detection (detect_lan_ip / is_tailscale_ipv4) now delegates to the same one predicate — detection and advertisement can't drift. Tests (lan.rs): advertise_hygiene_rejects_never_correct_classes_and_admits_real_lans, refresh_withdraws_a_poisoned_advertised_bridge_ip, refresh_refuses_to_advertise_a_known_peers_address. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): failure-counted eviction — 5 consecutive dial failures mark an endpoint DEAD until a fresher advertisement M5↔bigmama live repro decay mode #4 (the reader side): dozens of stored 172.x ghost endpoints burned a 3s timeout each on every discovery refresh, forever — timed backoff alone caps at 120s and never gives up. - DialQuarantine entries now carry a consecutive-failure streak and the endpoint-set freshness stamp the failures accrued under. New DialGate verdict: Dial / Backoff{remaining_ms} / Dead{count}. DEAD_AFTER_CONSECUTIVE_FAILURES = 5 (spans several backoff windows, so a flapping endpoint recovers long before eviction). - Dead = evicted from the dial set (record kept, endpoint skipped even after every backoff window elapses). Revival: a STRICTLY fresher advertisement stamp (item 1's endpoints_advertised_at_ms, plumbed per-peer through dial_stored_peer_endpoints) lifts the eviction for ONE probe; a failure under the fresher stamp re-kills immediately — so a live-but-unreachable publisher costs at most one 3s dial per fresh advertisement, and a genuinely revived daemon (same stable port, fresh ad) reconnects on the first probe. The retention sweep (10min idle) stays the bounded second-chance horizon. - Dead skips surface distinctly: PeerDialSkip.dead=true, and 'airc transport health' prints 'endpoint dead: … evicted after 5 consecutive failed dials; revived only by a fresher advertisement' instead of a misleading countdown. Test: dial_quarantine::fifth_consecutive_failure_is_dead_until_a_fresher_advertisement (dead after backoff expiry, not-dead below threshold, fresher-stamp one-probe revival + immediate re-kill, success wipes the streak). Also: peer_dials_lan_rung_and_skips_tailscale updated — the advertise hygiene now rightly refuses loopback as a LAN advertisement, so the dial-ladder test builds its imported endpoint set directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): airc dial <host:port> — manual authenticated recovery dial The hands-on override for a wedged mesh (M5↔bigmama live repro): one authenticated dial both proves reachability AND teaches the REMOTE our real source address via learn-live-address (#9), un-wedging its next outbound dial when its stored endpoint for us is stale. - New verb: airc dial HOST:PORT [--expected-peer <uuid>] [--timeout-ms N] (default 10s — a recovery verb watched by an operator, generous next to discovery's 3s sweep budget). Full mTLS-pinned handshake via the one existing connect_lan path; success stamps last_seen on the trust record (an authenticated dial IS fresh contact); success and failure both print LOUDLY with next-step hints; nonzero exit on failure. - --expected-peer is inferred when omitted: stored-endpoint exact match first, else the identity-derived stable port (#8, airc_lib::stable_lan_port, now pub for exactly this). Zero or several candidates is a loud error naming them — never a guess. - Flag named --expected-peer (not --peer) because --peer is the global volatile-peer-spec flag; same convention as lan-send. Test: cli::dial_verb_parses_endpoint_and_optional_peer (parse contract). Smoke-verified live: two temp homes, lan-listen + dial → 'CONNECTED: authenticated handshake … succeeded'; dead endpoint + un-enrolled peer both fail loudly with exit 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): unknown_channel auto-rebind — a registry-known channel is re-bound, never silently store-and-dropped M5↔bigmama live repro decay mode #5 (the blind room): a frame from a connected peer landed durably in the store but no scope bound its channel (#general → unknown_channel) — stored, never surfaced. Investigation (root cause): scope↔channel binding = presence beacons in the machine coordinator store; join/part/current_room already republish the scope's FULL subscription set (publish_presence uses the whole SubscriptionSet), so a joining scope does re-bind all its known rooms. What a restart/wipe loses is the MACHINE-side beacon table (drained stale rows, moved wire root, identity drift) — and nothing consulted the account registry's known-channel list to recover. The fix, at the one inbound seam (RouterInboundBridge): - (a) the loud diagnostic with channel + peer already exists at the transport layer (FrameUndeliverable, persisted=true) — unchanged. - (b) on an unbound channel, the bridge now consults the machine's LOCAL account-registry cache (SqliteAccountRegistryStore over the same events.sqlite, injected via with_account_registry): if the account KNOWS the channel (document channel union or any beacon's subscriptions derive to the frame's RoomId), the registry's subscribing beacons are republished into the coordinator store — restoring the durable binding — and the verdict is re-checked before claiming Delivered. New warn diagnostic UnknownChannelRebound (channel id + name + rebound beacon count). Channels the account does not know keep the honest unknown_channel verdict. Test: daemon_lan_visibility::unknown_channel_auto_rebinds_from_account_registry_cache (unknown stays unknown; known channel re-binds + delivers + is loud exactly once; the binding persists — no per-frame patching). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(identity): Windows-portable local fallback — never the degenerate local:unknown-host:unknown-user Root cause of the M5<->bigmama blind room, receiver-side log: 24 unknown_channel frames all on channel eef18336-1424-5231-bcf9-5b6f8e04deb6, which is exactly derive_room_id("local:unknown-host:unknown-user", "general"). On Windows every probe in local_fallback_identity() failed silently: HOSTNAME and USER/LOGNAME env vars do not exist there, and Windows hostname.exe rejects -s. Every gh-less Windows machine therefore collapsed onto the SAME degenerate identity, silently forking its room UUID derivation away from the account identity. The fallback chain now consults COMPUTERNAME/USERNAME and falls through to bare `hostname`, rejects empty/whitespace probe output, and is injectable for hermetic tests. Deliberately NOT changed: the keep-provisional-cache-on-gh-outage retention in resolve_with — churning a cached identity would re-derive rooms out from under live subscriptions; delivery for already-diverged machines is healed by the channel-name reconvergence commit on this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): channel-NAME reconvergence — a diverged room UUID no longer makes a bound room blind The M5<->bigmama fix, both directions. Channel UUIDs derive from (mesh_identity, name); when one machine's identity resolution forks (gh unreachable -> local:<host>:<user> fallback), its #general derives a DIFFERENT UUID than its peers', and every room frame between them dies as undeliverable{unknown_channel} while the room is bound and readable on both sides. Live receipts: bigmama (identity forked to local:unknown-host:unknown-user) sent #general frames to eef18336-...; M5 binds 5eedf7b1-... under the gh login — 24 frames durably stored, never surfaced, and the reverse direction refused symmetrically. The heal, extending the existing seams: - Senders stamp the human channel name on every room send (HEADER_AIRC_CHANNEL_NAME, stamped in send_frame_to_room + daemon_send_text/daemon_send_frame/daemon_publish via Room::stamp_name_header) — the cross-machine convergence key. It rides envelope headers, so the routed forwarder and multi-hop re-forwards carry it for free. - RouterInboundBridge::deliver resolves the local binding BEFORE publish; when the addressed UUID binds no scope but the frame's name header derives — under THIS machine's identity — to a channel that is bound (directly, or after the existing account-registry rebind heal), the frame is published under the LOCAL channel and the verdict is the new DeliveredRemapped(local). A bound channel is never re-routed: the name is a heal hint from an authenticated enrolled peer, not addressing authority. - Transport ack + handle fan-out follow the remapped channel (DeliveryOutcome::Delivered carries the receiver's local room), and the heal is LOUD: DiagnosticCode::ChannelNameReconverged names the addressed UUID, local UUID, room name, and sender so the operator sees the sending machine's identity is split. Regression tests (daemon_lan_visibility): the literal field fingerprint (eef18336 = derive("local:unknown-host:unknown-user","general")) reconverging into the bound room at bridge level with all three controls (no header / unbound name / bound-channel-never-rerouted), and the full field scenario end-to-end over a real TLS LAN link — a remote pinned to the degenerate identity joins #general, dials in, sends with delivery ack, the ack says Delivered carrying the receiver's local room, and the operator scope reads the message. Identity pinning is hermetic via mesh_identity Operator-source entries (Airc::coordinator_store_for_test, same pattern as the existing _for_test seams). Both machines must run this build for both directions to heal: the sender stamps, the receiver reconverges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(clippy): drop redundant closure on the endpoints stamp (unblock #1288 merge gate) `cargo clippy -D warnings` (a merge gate) flagged `redundant_closure` at account_registry.rs:577 — `.then(|| crate::time::now_ms())` wraps a fn that can be passed directly. Pass `crate::time::now_ms` to `.then`. Semantically identical (the `.transpose()?` still sees the Result); clears the only red check on #1288. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * fix(identity): stable machine-id fallback — never emit the colliding unknown-host sentinel (#6) Root cause of the M5<->bigmama blind-room bug: when every host/user probe failed (a bare daemon process with no HOSTNAME/USER/COMPUTERNAME/USERNAME and a rejected `hostname`), local_fallback_identity() silently produced `local:unknown-host:unknown-user` — the SAME degenerate identity on every such machine. Two machines then derived divergent `#general` UUIDs and every frame between them died as `unknown_channel`. Fix: introduce a persisted, machine-wide `machine_id()` (`~/.airc/machine-id`, one UUID per machine shared across scopes) and, on any probe failure, anchor the degraded identity to it (`local:machine-<id>:...`) instead of the colliding sentinel — so probe-blind machines stay DISTINCT — and shout loudly (never silent) that identity degraded and will self-heal to gh. Same primitive is the intended canonical key for the account-registry gist name (#5: one gist per machine regardless of hostname resolution). Tests: new `local_fallback_uses_machine_id_when_all_probes_fail` (asserts no sentinel + distinct-per-machine); existing Windows-fallback tests updated for the injected machine-id. 16/16 mesh_identity tests green on windows-msvc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * fix(registry): prune stale beacons at the GH publish path (#4) `airc network` kept reporting ~46 stale beacons because they were dropped from the live reader-merge and the FS store but NEVER from the persisted GitHub gist: the GH `publish()` serialized `document.peers` verbatim, so a machine's own gist accumulated dead beacons forever (past sessions, dead containers). Fix: apply the ONE canonical `prune_stale_peers` (the same primitive the reader-merge and FS store already use, `DEFAULT_PEER_FRESHNESS_TTL_MS`) at the missing write site, before serialization — clone because the trait hands us `&document`. Emits the existing AccountRegistryStaleBeaconsPruned diagnostic when it drops any. Now a republish self-cleans the gist instead of growing it. 27/27 account_registry tests green on windows-msvc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * feat(self-heal): auto-reconnect after restart — boot dials stored live peers NOW, and a machine-vs-scope identity mismatch retries once pinned to the presented enrolled identity Item 1 of the restart-reconnect heal (live two-machine evidence: every daemon restart drops the transport sessions and reconnection never converged unaided — a manual `airc dial` fixed it every time). Two seams, no parallel dialers: - FIRST_REFRESH_DELAY 5s → ZERO. run_daemon spawns the route-refresh loop only after identity/trust/router are built, so there is nothing to "settle": the first stored-peer dial pass runs at boot instead of waiting out a tick. The pass already honors ghost-freshness and the dial-quarantine gates (eviction/backoff respected), so an immediate tick cannot stampede dead endpoints. - Identity-mismatch retry in the ONE pinned dial path (dial_one_peer): when a scope-pinned dial fails with the verifier's loud "server cert is for peer X, expected Y" naming a DIFFERENT identity, and X is ENROLLED, retry exactly once pinned to X — the same recovery a human performs with the error in hand. Strictness unchanged: an unenrolled presented identity is never retried, never accepted. The error format and its parser live side by side in the verifier (peer_identity_mismatch_error / presented_peer_from_mismatch_error) so they cannot drift. Tests: - route_refresh: boot_schedules_the_first_refresh_immediately (the restart-shaped regression), first_refresh_is_immediate_then_steady_interval, shutdown test updated for the boot-immediate tick. - verifier: mismatch_error_round_trips_presented_peer_through_the_parser, wrong_peer_rejection_is_parseable_by_the_mismatch_parser. - stored_endpoint_dial: identity_mismatch_dial_retries_once_pinning_the_presented_enrolled_identity, identity_mismatch_never_retries_an_unenrolled_identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): receive-binding re-derive on identity heal — a healed mesh identity re-binds stale subscriptions to their converged rooms Item 2 of the restart-reconnect heal (live sequel to d79843c): after the Windows identity healed, sends derived CONVERGED channel UUIDs but the scope's stored subscriptions kept the OLD diverged UUIDs — the per-frame name reconvergence heals delivery TO a bound room, but a room bound under a stale UUID must be REBOUND, or the scope keeps reading (and attaching to) the dead room. Live evidence: only a manual `airc stop && airc join` un-wedged it. The heal: a subscription's channel NAME is its durable identity; the room UUID is a derivation frozen at join time. SubscriptionSet:: rebind_diverged re-derives every subscription under the current mesh identity and re-binds any that diverged (name/wire/joined_at preserved, one loud old→new warning per move). Wired into the two join-shaped touchpoints — Airc::join and Airc::ensure_join_context (bare `airc join`, init re-runs, daemon-bounce recovery, monitor resume) — right after mesh_identity() resolves, so the very next join after a heal converges with zero manual steps. save + publish_presence on the same paths persist the rebind and re-beacon the channel names under the healed identity. Read cursors need no migration: runtime cursors are keyed per consumer id over the owner-core's GLOBAL (epoch, counter) order, not per room UUID. Tests: - subscriptions: rebind_diverged_moves_room_ids_to_the_current_identity_derivation (pure rule: re-derive, preserve, report, idempotent). - daemon_lan_visibility: healed_identity_rebinds_stale_subscription_to_the_converged_room (end-to-end: diverged join → heal → rebind at join → a frame addressed to the converged UUID delivers AND the scope reads it; the old diverged UUID keeps delivering via the existing name-reconvergence remap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(registry): key the registry gist on the stable machine-id, not hostname (#5 part 1/2) writer_key() was `<host>-<user>`, which STILL fragmented: `hostname` resolves three ways on one Mac (joels-macbook-pro-local / joels-mbp-lan / macbookpro-lan), minting THREE registry gists for a single machine (Joel's "47" was largely this). Re-key to the canonical, persisted machine_id (`~/.airc/machine-id`, one value per box shared across scopes, from #6) — stable regardless of hostname resolution, so a box maps to exactly ONE registry gist even when its sentinel / events.sqlite is wiped. Human-readable host/platform stays in the beacon content, not the filename. Removed the now-dead host-resolution helpers (first_nonempty_env / hostname_from_command / sanitize_writer_component) and their test rather than leave dead code. Part 2/2 (follow-up commit): teach the opt-in `airc registry gc` to reap gists whose beacons are ALL stale, so the orphaned old `<host>-<user>` gists (which stop receiving fresh beacons once a box adopts this key) get cleaned instead of lingering. Reader-merge already ignores their stale beacons, so routing is correct in the meantime. 26/26 account_registry tests green on windows-msvc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * fix(registry): reap superseded/dead all-stale gists in the opt-in gc (#5 part 2/2) Completes #5: with writer_key now on the stable machine-id (part 1), a box's old `<host>-<user>` gists stop receiving fresh beacons and linger. Teach `airc registry gc` to reap them. The pure filename classifier stays untouched (still unit-testable); the freshness dimension lives in the `gc()` I/O method: for each Kept, real machine-keyed gist (never our own, never junk) it fetches the body and downgrades Keep->Delete when `has_only_stale_beacons` — the doc carries beacons and EVERY one is past DEFAULT_PEER_FRESHNESS_TTL_MS. Conservative: an EMPTY document is NOT reapable (a just-created gist mid-first-write — never delete on ambiguity), and an unreadable body leaves the gist Kept (never delete on a blind fetch). Reaping is safe because the owner recreates a fresh gist on return via the sentinel; gc is opt-in and dry-run by default, so the operator reviews the plan first. New `has_only_stale_beacons_reaps_all_stale_but_never_fresh_or_empty` test (fresh-keeps / all-stale-reaps / empty-keeps). Validated in a Linux container (gh_stub is unix-gated): 40/40 account_registry tests green; windows-msvc clippy + non-gated tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * feat(self-heal): machine-vs-scope cert identity — beacons carry the transport host, dialers pin it first time, whois joins the machine↔scope card Item 3 of the restart-reconnect heal (live evidence: endpoints answer TLS with the MACHINE identity e85a… while scopes send as SCOPE peers ce8b…; every scope-pinned dial died in a loud mismatch until a human redialed by machine id). The model — one mapping, carried end to end with the endpoints it describes (never a parallel table): - AccountPeerBeacon.endpoints_peer_id (serde-default, skip-if-none — old documents decode, old readers see unchanged wire): the TLS cert identity that answers at the beacon's endpoints. DISTINCT from the mesh-identity machine-id (a registry rendezvous key string) — this is the daemon keypair identity, joinable with it in whois. - Publisher: `registry sync` from a daemon-attached scope reads the daemon's endpoints back over IPC — it now also resolves the daemon's peer id (Status) and stamps it via Airc::set_advertised_endpoints_host, so the published self-beacon names WHO answers there. Handles that own their listener publish no mapping (endpoints answer as themselves). - Merge: the endpoint-carrier backfill carries the CARRIER's mapping with its endpoints, exactly like the freshness stamp. - Import: persisted on the trust record (new nullable peer_trust column endpoints_peer_id, migration m20260723_000020) atomically WITH the endpoint set under the same monotonic stamp; self-mappings are normalized away. - Dial: resolve_dial_pin (pure) — pin the declared host ONLY when it is a different peer AND itself enrolled; unenrolled/self/absent mappings pin the record's own peer. Strictness unchanged: an unknown identity is never dialed-for, never accepted — a poisoned mapping can at worst redirect the pin to another already-trusted peer. A record hosted by an already-connected machine is skipped (route is up, no re-dial churn); item 1's one-shot mismatch retry remains the fallback for records that predate the mapping. - Surfaces: `airc whois <peer>` shows the machine line (transport host) and, for a machine, the scope peers it hosts — one card; `airc peer list` adds host= (human) and endpoints_peer_id (JSON). Tests: - store: mapping written/refused/preserved atomically with endpoints (extended replace/rotation + monotonic-stamp tests). - account_registry: merge_backfill_carries_the_carriers_endpoints_host_mapping (backfill + serde default/skip round-trip), import_stores_the_endpoints_host_mapping_normalized. - discovery: dial_pin_honors_only_an_enrolled_foreign_host_mapping (strictness matrix). - stored_endpoint_dial: stored_host_mapping_pins_the_machine_identity_and_connects (first-dial pin + steady-state no-churn); the unenrolled-identity loud-failure test from item 1 pins the never-accept-unknown gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * style: cargo fmt the self-healing batch (unblock #1288 fmt gate) `cargo fmt --check` was the one red check on #1288 — my registry changes went through `cargo test` + `clippy` but not `cargo fmt`, so rustfmt's line-wrapping of has_only_stale_beacons (gh/account_registry.rs) failed the gate. No logic change; formatting only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * feat(hooks): pre-push cargo fmt + clippy gate — catch CI-gate failures locally (cab380c0) Recurring cost: pushing airc Rust changes without `cargo fmt` (or with clippy warnings) fails the `cargo fmt --check` / `cargo clippy -D warnings` CI gates — each a multi-minute round-trip plus a manual fixup commit (bit both #1288 and #1289 in one session). This runs both LOCALLY at pre-push so the failure surfaces in seconds, before the push leaves the machine. - New worker `integrations/git-hooks/airc-cargo-gate.sh`: phase-aware (no-op except on pre-push — fmt+clippy on every commit is too slow), fail-open on a toolchain gap (no cargo / not the workspace → exit 0, never block), fail-closed on real fmt/clippy violations. Shares the one CARGO_TARGET_DIR so clippy reuses incremental artifacts. Escape hatches: AIRC_HOOK_SKIP / AIRC_CARGO_GATE_SKIP / AIRC_CARGO_GATE_CLIPPY=0. - `_install_airc_git_hooks` wires it into the generated pre-commit/pre-push hooks alongside the existing fetch-staleness worker (the worker self-gates to push). Completes the flywheel front-end: pre-push gate → CI → the board's merger daemon auto-merges green Review cards. Validated: no-op on pre-commit, honors the skip, fmt gate clean on canary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…-on-failure, dial verb, unknown_channel auto-rebind (#1288) * feat(self-heal): atomic stamped endpoint replace — a fresher advertisement fully replaces, a staler one is refused M5↔bigmama live repro #2: a registry re-sync took a peer's new IP but kept its stale port, dialing a dead endpoint forever. Root cause: the stored endpoint set had no freshness, so nothing could order an old advertisement against a new one, and merge-backfilled stale endpoints could ride a fresh presence into the store. - peer_trust gains endpoints_advertised_at_ms (additive nullable migration; NULL floors to 0 so any stamped ad outranks legacy rows). - set_peer_trust_endpoints now writes endpoints + stamp atomically and replaces monotonically: staler stamp = whole write refused. - AccountPeerBeacon carries endpoints_advertised_at_ms (serde-default, old documents keep decoding); publishers stamp at generation; the reader-side merge backfill keeps the CARRIER's stamp so stale endpoints never masquerade as fresh; import clamps the peer-asserted stamp to now (same doctrine as the last_seen security clamp). Tests: - sqlite: fresher_advertisement_atomically_replaces_endpoints_and_staler_is_refused - sqlite: replace_peer_trust_preserves_stored_endpoints now pins the stamp too - account_registry: import_fresher_advertisement_fully_replaces_endpoint_and_stale_is_refused (the exact (ip1,port1)->(ip2,port2) regression + stale replay + fresh-presence/stale-endpoints composite) - account_registry: merge_retains_endpoints_when_fresher_beacon_is_endpointless now pins carrier-stamp backfill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): refresh-on-failure — a failed dial re-reads the rendezvous before any blind retry M5↔bigmama live repro #1: after a daemon restart, the remote peer kept dialing the dead port (.249:58842 vs live .249:57958) forever, never re-reading the rendezvous. - Airc::heal_failed_dials(store, failures): record the failed peers' stored endpoint sets, do ONE rendezvous re-read + import (atomic + monotonic per the item-1 stamp, so it can never make things worse), and re-dial ONLY when a failed peer's endpoints actually changed — through the one pinned dial path (cost order, quarantine skips, stop on first success). No fresher endpoint = no extra dial pass: the dial-quarantine backoff (15s→120s) keeps owning the dead endpoint's retry cadence, so this is bounded by construction. - Daemon wiring: the registry task shares its ONE resolved rendezvous (store + gate) via an Arc<OnceLock> slot; refresh_routes_once heals after emitting dial-failure diagnostics, honoring the same gh/hermetic gate as registry ticks, and the healed snapshot feeds the connected count + relay self-election. - New Arc<dyn AccountRegistryStore> delegating impl (mirrors Box) so one resolved door serves both loops without a second resolution. Test: stored_endpoint_dial::heal_failed_dials_rereads_rendezvous_and_dials_fresh_endpoint (stub AccountRegistryStore rendezvous — unchanged rendezvous heals nothing; fresh advertisement is imported, replaces the corpse, and the live endpoint is dialed immediately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): publish-on-bind — a freshly bound listener propagates to the rendezvous immediately M5↔bigmama live repro #1 (the propagation half): a daemon restart that lands on a new port only advertised it at the refresh loop's first cadence tick, leaving every peer dialing the dead port meanwhile. - Registry task nudges endpoint_resync the moment listen_lan_advertising binds; Notify stores the permit, so the refresh loop's biased select publishes IMMEDIATELY on start instead of waiting out first_tick. Idempotent: an unchanged advertisement republishes the same document. - Relay self-election (become_relay binds a listener too) now nudges the same resync so the new relay endpoint propagates without waiting up to a full 120s cadence. - (IP-move rebinds were already edge-triggered via refresh_advertised_endpoints Ok(true) — this closes the two bind sites that were not.) Test: registry_refresh::pre_loop_resync_permit_publishes_immediately_not_at_first_tick (first_tick/cadence at 3600s — only the stored permit can publish in time; pins the Notify-permit-before-loop-start contract). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): advertise hygiene — never-correct addresses and known-peer collisions are refused (and withdrawn) M5↔bigmama live repro decay modes #3 and #4: Docker containers advertised internal bridge IPs (172.x) as reachable endpoints — dozens of 3s dial timeouts per refresh across every peer — and one peer record carried the reader's OWN Tailscale IP as another machine's endpoint. - ONE hygiene predicate pair in airc-lib (lan_advertise_rejection / tailscale_advertise_rejection): loopback, unspecified, link-local 169.254/16, the 172.16/12 docker/bridge band, and CGNAT-on-the-LAN- rung are never advertised; the Tailscale rung only carries 100.64/10. RFC1918 10/8 + 192.168/16 (en0-style) and public addresses pass. The 172.16/12 refusal is by RANGE (the UDP source-address detection has no ifname) — per the live evidence that band is never correct on this mesh, and such a host still advertises its Tailscale rung. - Known-peer collision guard: an endpoint equal to a KNOWN other peer's stored endpoint is refused loudly — advertising it would extend the corrupted-record chain onto the rendezvous. - Applied at BOTH advertise seams (listen_lan_advertising and the per-tick refresh_advertised_endpoints): a rejected IP reads as None, so an already-poisoned advertised rung is WITHDRAWN on the next tick (self-heal), edge-triggering a corrected registry publish. - CLI detection (detect_lan_ip / is_tailscale_ipv4) now delegates to the same one predicate — detection and advertisement can't drift. Tests (lan.rs): advertise_hygiene_rejects_never_correct_classes_and_admits_real_lans, refresh_withdraws_a_poisoned_advertised_bridge_ip, refresh_refuses_to_advertise_a_known_peers_address. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): failure-counted eviction — 5 consecutive dial failures mark an endpoint DEAD until a fresher advertisement M5↔bigmama live repro decay mode #4 (the reader side): dozens of stored 172.x ghost endpoints burned a 3s timeout each on every discovery refresh, forever — timed backoff alone caps at 120s and never gives up. - DialQuarantine entries now carry a consecutive-failure streak and the endpoint-set freshness stamp the failures accrued under. New DialGate verdict: Dial / Backoff{remaining_ms} / Dead{count}. DEAD_AFTER_CONSECUTIVE_FAILURES = 5 (spans several backoff windows, so a flapping endpoint recovers long before eviction). - Dead = evicted from the dial set (record kept, endpoint skipped even after every backoff window elapses). Revival: a STRICTLY fresher advertisement stamp (item 1's endpoints_advertised_at_ms, plumbed per-peer through dial_stored_peer_endpoints) lifts the eviction for ONE probe; a failure under the fresher stamp re-kills immediately — so a live-but-unreachable publisher costs at most one 3s dial per fresh advertisement, and a genuinely revived daemon (same stable port, fresh ad) reconnects on the first probe. The retention sweep (10min idle) stays the bounded second-chance horizon. - Dead skips surface distinctly: PeerDialSkip.dead=true, and 'airc transport health' prints 'endpoint dead: … evicted after 5 consecutive failed dials; revived only by a fresher advertisement' instead of a misleading countdown. Test: dial_quarantine::fifth_consecutive_failure_is_dead_until_a_fresher_advertisement (dead after backoff expiry, not-dead below threshold, fresher-stamp one-probe revival + immediate re-kill, success wipes the streak). Also: peer_dials_lan_rung_and_skips_tailscale updated — the advertise hygiene now rightly refuses loopback as a LAN advertisement, so the dial-ladder test builds its imported endpoint set directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): airc dial <host:port> — manual authenticated recovery dial The hands-on override for a wedged mesh (M5↔bigmama live repro): one authenticated dial both proves reachability AND teaches the REMOTE our real source address via learn-live-address (#9), un-wedging its next outbound dial when its stored endpoint for us is stale. - New verb: airc dial HOST:PORT [--expected-peer <uuid>] [--timeout-ms N] (default 10s — a recovery verb watched by an operator, generous next to discovery's 3s sweep budget). Full mTLS-pinned handshake via the one existing connect_lan path; success stamps last_seen on the trust record (an authenticated dial IS fresh contact); success and failure both print LOUDLY with next-step hints; nonzero exit on failure. - --expected-peer is inferred when omitted: stored-endpoint exact match first, else the identity-derived stable port (#8, airc_lib::stable_lan_port, now pub for exactly this). Zero or several candidates is a loud error naming them — never a guess. - Flag named --expected-peer (not --peer) because --peer is the global volatile-peer-spec flag; same convention as lan-send. Test: cli::dial_verb_parses_endpoint_and_optional_peer (parse contract). Smoke-verified live: two temp homes, lan-listen + dial → 'CONNECTED: authenticated handshake … succeeded'; dead endpoint + un-enrolled peer both fail loudly with exit 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): unknown_channel auto-rebind — a registry-known channel is re-bound, never silently store-and-dropped M5↔bigmama live repro decay mode #5 (the blind room): a frame from a connected peer landed durably in the store but no scope bound its channel (#general → unknown_channel) — stored, never surfaced. Investigation (root cause): scope↔channel binding = presence beacons in the machine coordinator store; join/part/current_room already republish the scope's FULL subscription set (publish_presence uses the whole SubscriptionSet), so a joining scope does re-bind all its known rooms. What a restart/wipe loses is the MACHINE-side beacon table (drained stale rows, moved wire root, identity drift) — and nothing consulted the account registry's known-channel list to recover. The fix, at the one inbound seam (RouterInboundBridge): - (a) the loud diagnostic with channel + peer already exists at the transport layer (FrameUndeliverable, persisted=true) — unchanged. - (b) on an unbound channel, the bridge now consults the machine's LOCAL account-registry cache (SqliteAccountRegistryStore over the same events.sqlite, injected via with_account_registry): if the account KNOWS the channel (document channel union or any beacon's subscriptions derive to the frame's RoomId), the registry's subscribing beacons are republished into the coordinator store — restoring the durable binding — and the verdict is re-checked before claiming Delivered. New warn diagnostic UnknownChannelRebound (channel id + name + rebound beacon count). Channels the account does not know keep the honest unknown_channel verdict. Test: daemon_lan_visibility::unknown_channel_auto_rebinds_from_account_registry_cache (unknown stays unknown; known channel re-binds + delivers + is loud exactly once; the binding persists — no per-frame patching). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(identity): Windows-portable local fallback — never the degenerate local:unknown-host:unknown-user Root cause of the M5<->bigmama blind room, receiver-side log: 24 unknown_channel frames all on channel eef18336-1424-5231-bcf9-5b6f8e04deb6, which is exactly derive_room_id("local:unknown-host:unknown-user", "general"). On Windows every probe in local_fallback_identity() failed silently: HOSTNAME and USER/LOGNAME env vars do not exist there, and Windows hostname.exe rejects -s. Every gh-less Windows machine therefore collapsed onto the SAME degenerate identity, silently forking its room UUID derivation away from the account identity. The fallback chain now consults COMPUTERNAME/USERNAME and falls through to bare `hostname`, rejects empty/whitespace probe output, and is injectable for hermetic tests. Deliberately NOT changed: the keep-provisional-cache-on-gh-outage retention in resolve_with — churning a cached identity would re-derive rooms out from under live subscriptions; delivery for already-diverged machines is healed by the channel-name reconvergence commit on this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): channel-NAME reconvergence — a diverged room UUID no longer makes a bound room blind The M5<->bigmama fix, both directions. Channel UUIDs derive from (mesh_identity, name); when one machine's identity resolution forks (gh unreachable -> local:<host>:<user> fallback), its #general derives a DIFFERENT UUID than its peers', and every room frame between them dies as undeliverable{unknown_channel} while the room is bound and readable on both sides. Live receipts: bigmama (identity forked to local:unknown-host:unknown-user) sent #general frames to eef18336-...; M5 binds 5eedf7b1-... under the gh login — 24 frames durably stored, never surfaced, and the reverse direction refused symmetrically. The heal, extending the existing seams: - Senders stamp the human channel name on every room send (HEADER_AIRC_CHANNEL_NAME, stamped in send_frame_to_room + daemon_send_text/daemon_send_frame/daemon_publish via Room::stamp_name_header) — the cross-machine convergence key. It rides envelope headers, so the routed forwarder and multi-hop re-forwards carry it for free. - RouterInboundBridge::deliver resolves the local binding BEFORE publish; when the addressed UUID binds no scope but the frame's name header derives — under THIS machine's identity — to a channel that is bound (directly, or after the existing account-registry rebind heal), the frame is published under the LOCAL channel and the verdict is the new DeliveredRemapped(local). A bound channel is never re-routed: the name is a heal hint from an authenticated enrolled peer, not addressing authority. - Transport ack + handle fan-out follow the remapped channel (DeliveryOutcome::Delivered carries the receiver's local room), and the heal is LOUD: DiagnosticCode::ChannelNameReconverged names the addressed UUID, local UUID, room name, and sender so the operator sees the sending machine's identity is split. Regression tests (daemon_lan_visibility): the literal field fingerprint (eef18336 = derive("local:unknown-host:unknown-user","general")) reconverging into the bound room at bridge level with all three controls (no header / unbound name / bound-channel-never-rerouted), and the full field scenario end-to-end over a real TLS LAN link — a remote pinned to the degenerate identity joins #general, dials in, sends with delivery ack, the ack says Delivered carrying the receiver's local room, and the operator scope reads the message. Identity pinning is hermetic via mesh_identity Operator-source entries (Airc::coordinator_store_for_test, same pattern as the existing _for_test seams). Both machines must run this build for both directions to heal: the sender stamps, the receiver reconverges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(clippy): drop redundant closure on the endpoints stamp (unblock #1288 merge gate) `cargo clippy -D warnings` (a merge gate) flagged `redundant_closure` at account_registry.rs:577 — `.then(|| crate::time::now_ms())` wraps a fn that can be passed directly. Pass `crate::time::now_ms` to `.then`. Semantically identical (the `.transpose()?` still sees the Result); clears the only red check on #1288. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * fix(identity): stable machine-id fallback — never emit the colliding unknown-host sentinel (#6) Root cause of the M5<->bigmama blind-room bug: when every host/user probe failed (a bare daemon process with no HOSTNAME/USER/COMPUTERNAME/USERNAME and a rejected `hostname`), local_fallback_identity() silently produced `local:unknown-host:unknown-user` — the SAME degenerate identity on every such machine. Two machines then derived divergent `#general` UUIDs and every frame between them died as `unknown_channel`. Fix: introduce a persisted, machine-wide `machine_id()` (`~/.airc/machine-id`, one UUID per machine shared across scopes) and, on any probe failure, anchor the degraded identity to it (`local:machine-<id>:...`) instead of the colliding sentinel — so probe-blind machines stay DISTINCT — and shout loudly (never silent) that identity degraded and will self-heal to gh. Same primitive is the intended canonical key for the account-registry gist name (#5: one gist per machine regardless of hostname resolution). Tests: new `local_fallback_uses_machine_id_when_all_probes_fail` (asserts no sentinel + distinct-per-machine); existing Windows-fallback tests updated for the injected machine-id. 16/16 mesh_identity tests green on windows-msvc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * fix(registry): prune stale beacons at the GH publish path (#4) `airc network` kept reporting ~46 stale beacons because they were dropped from the live reader-merge and the FS store but NEVER from the persisted GitHub gist: the GH `publish()` serialized `document.peers` verbatim, so a machine's own gist accumulated dead beacons forever (past sessions, dead containers). Fix: apply the ONE canonical `prune_stale_peers` (the same primitive the reader-merge and FS store already use, `DEFAULT_PEER_FRESHNESS_TTL_MS`) at the missing write site, before serialization — clone because the trait hands us `&document`. Emits the existing AccountRegistryStaleBeaconsPruned diagnostic when it drops any. Now a republish self-cleans the gist instead of growing it. 27/27 account_registry tests green on windows-msvc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * feat(self-heal): auto-reconnect after restart — boot dials stored live peers NOW, and a machine-vs-scope identity mismatch retries once pinned to the presented enrolled identity Item 1 of the restart-reconnect heal (live two-machine evidence: every daemon restart drops the transport sessions and reconnection never converged unaided — a manual `airc dial` fixed it every time). Two seams, no parallel dialers: - FIRST_REFRESH_DELAY 5s → ZERO. run_daemon spawns the route-refresh loop only after identity/trust/router are built, so there is nothing to "settle": the first stored-peer dial pass runs at boot instead of waiting out a tick. The pass already honors ghost-freshness and the dial-quarantine gates (eviction/backoff respected), so an immediate tick cannot stampede dead endpoints. - Identity-mismatch retry in the ONE pinned dial path (dial_one_peer): when a scope-pinned dial fails with the verifier's loud "server cert is for peer X, expected Y" naming a DIFFERENT identity, and X is ENROLLED, retry exactly once pinned to X — the same recovery a human performs with the error in hand. Strictness unchanged: an unenrolled presented identity is never retried, never accepted. The error format and its parser live side by side in the verifier (peer_identity_mismatch_error / presented_peer_from_mismatch_error) so they cannot drift. Tests: - route_refresh: boot_schedules_the_first_refresh_immediately (the restart-shaped regression), first_refresh_is_immediate_then_steady_interval, shutdown test updated for the boot-immediate tick. - verifier: mismatch_error_round_trips_presented_peer_through_the_parser, wrong_peer_rejection_is_parseable_by_the_mismatch_parser. - stored_endpoint_dial: identity_mismatch_dial_retries_once_pinning_the_presented_enrolled_identity, identity_mismatch_never_retries_an_unenrolled_identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(self-heal): receive-binding re-derive on identity heal — a healed mesh identity re-binds stale subscriptions to their converged rooms Item 2 of the restart-reconnect heal (live sequel to d79843c): after the Windows identity healed, sends derived CONVERGED channel UUIDs but the scope's stored subscriptions kept the OLD diverged UUIDs — the per-frame name reconvergence heals delivery TO a bound room, but a room bound under a stale UUID must be REBOUND, or the scope keeps reading (and attaching to) the dead room. Live evidence: only a manual `airc stop && airc join` un-wedged it. The heal: a subscription's channel NAME is its durable identity; the room UUID is a derivation frozen at join time. SubscriptionSet:: rebind_diverged re-derives every subscription under the current mesh identity and re-binds any that diverged (name/wire/joined_at preserved, one loud old→new warning per move). Wired into the two join-shaped touchpoints — Airc::join and Airc::ensure_join_context (bare `airc join`, init re-runs, daemon-bounce recovery, monitor resume) — right after mesh_identity() resolves, so the very next join after a heal converges with zero manual steps. save + publish_presence on the same paths persist the rebind and re-beacon the channel names under the healed identity. Read cursors need no migration: runtime cursors are keyed per consumer id over the owner-core's GLOBAL (epoch, counter) order, not per room UUID. Tests: - subscriptions: rebind_diverged_moves_room_ids_to_the_current_identity_derivation (pure rule: re-derive, preserve, report, idempotent). - daemon_lan_visibility: healed_identity_rebinds_stale_subscription_to_the_converged_room (end-to-end: diverged join → heal → rebind at join → a frame addressed to the converged UUID delivers AND the scope reads it; the old diverged UUID keeps delivering via the existing name-reconvergence remap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(registry): key the registry gist on the stable machine-id, not hostname (#5 part 1/2) writer_key() was `<host>-<user>`, which STILL fragmented: `hostname` resolves three ways on one Mac (joels-macbook-pro-local / joels-mbp-lan / macbookpro-lan), minting THREE registry gists for a single machine (Joel's "47" was largely this). Re-key to the canonical, persisted machine_id (`~/.airc/machine-id`, one value per box shared across scopes, from #6) — stable regardless of hostname resolution, so a box maps to exactly ONE registry gist even when its sentinel / events.sqlite is wiped. Human-readable host/platform stays in the beacon content, not the filename. Removed the now-dead host-resolution helpers (first_nonempty_env / hostname_from_command / sanitize_writer_component) and their test rather than leave dead code. Part 2/2 (follow-up commit): teach the opt-in `airc registry gc` to reap gists whose beacons are ALL stale, so the orphaned old `<host>-<user>` gists (which stop receiving fresh beacons once a box adopts this key) get cleaned instead of lingering. Reader-merge already ignores their stale beacons, so routing is correct in the meantime. 26/26 account_registry tests green on windows-msvc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * fix(registry): reap superseded/dead all-stale gists in the opt-in gc (#5 part 2/2) Completes #5: with writer_key now on the stable machine-id (part 1), a box's old `<host>-<user>` gists stop receiving fresh beacons and linger. Teach `airc registry gc` to reap them. The pure filename classifier stays untouched (still unit-testable); the freshness dimension lives in the `gc()` I/O method: for each Kept, real machine-keyed gist (never our own, never junk) it fetches the body and downgrades Keep->Delete when `has_only_stale_beacons` — the doc carries beacons and EVERY one is past DEFAULT_PEER_FRESHNESS_TTL_MS. Conservative: an EMPTY document is NOT reapable (a just-created gist mid-first-write — never delete on ambiguity), and an unreadable body leaves the gist Kept (never delete on a blind fetch). Reaping is safe because the owner recreates a fresh gist on return via the sentinel; gc is opt-in and dry-run by default, so the operator reviews the plan first. New `has_only_stale_beacons_reaps_all_stale_but_never_fresh_or_empty` test (fresh-keeps / all-stale-reaps / empty-keeps). Validated in a Linux container (gh_stub is unix-gated): 40/40 account_registry tests green; windows-msvc clippy + non-gated tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc * feat(self-heal): machine-vs-scope cert identity — beacons carry the transport host, dialers pin it first time, whois joins the machine↔scope card Item 3 of the restart-reconnect heal (live evidence: endpoints answer TLS with the MACHINE identity e85a… while scopes send as SCOPE peers ce8b…; every scope-pinned dial died in a loud mismatch until a human redialed by machine id). The model — one mapping, carried end to end with the endpoints it describes (never a parallel table): - AccountPeerBeacon.endpoints_peer_id (serde-default, skip-if-none — old documents decode, old readers see unchanged wire): the TLS cert identity that answers at the beacon's endpoints. DISTINCT from the mesh-identity machine-id (a registry rendezvous key string) — this is the daemon keypair identity, joinable with it in whois. - Publisher: `registry sync` from a daemon-attached scope reads the daemon's endpoints back over IPC — it now also resolves the daemon's peer id (Status) and stamps it via Airc::set_advertised_endpoints_host, so the published self-beacon names WHO answers there. Handles that own their listener publish no mapping (endpoints answer as themselves). - Merge: the endpoint-carrier backfill carries the CARRIER's mapping with its endpoints, exactly like the freshness stamp. - Import: persisted on the trust record (new nullable peer_trust column endpoints_peer_id, migration m20260723_000020) atomically WITH the endpoint set under the same monotonic stamp; self-mappings are normalized away. - Dial: resolve_dial_pin (pure) — pin the declared host ONLY when it is a different peer AND itself enrolled; unenrolled/self/absent mappings pin the record's own peer. Strictness unchanged: an unknown identity is never dialed-for, never accepted — a poisoned mapping can at worst redirect the pin to another already-trusted peer. A record hosted by an already-connected machine is skipped (route is up, no re-dial churn); item 1's one-shot mismatch retry remains the fallback for records that predate the mapping. - Surfaces: `airc whois <peer>` shows the machine line (transport host) and, for a machine, the scope peers it hosts — one card; `airc peer list` adds host= (human) and endpoints_peer_id (JSON). Tests: - store: mapping written/refused/preserved atomically with endpoints (extended replace/rotation + monotonic-stamp tests). - account_registry: merge_backfill_carries_the_carriers_endpoints_host_mapping (backfill + serde default/skip round-trip), import_stores_the_endpoints_host_mapping_normalized. - discovery: dial_pin_honors_only_an_enrolled_foreign_host_mapping (strictness matrix). - stored_endpoint_dial: stored_host_mapping_pins_the_machine_identity_and_connects (first-dial pin + steady-state no-churn); the unenrolled-identity loud-failure test from item 1 pins the never-accept-unknown gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * style: cargo fmt the self-healing batch (unblock #1288 fmt gate) `cargo fmt --check` was the one red check on #1288 — my registry changes went through `cargo test` + `clippy` but not `cargo fmt`, so rustfmt's line-wrapping of has_only_stale_beacons (gh/account_registry.rs) failed the gate. No logic change; formatting only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…coning peer no longer stuck at 0-connected (#240) (#1293) * fix(work): settled cards (Review/Merged/Closed) are not claimable — claims gate on CardState, not just lease expiry Live evidence (2026-07-24): personas repeatedly re-claimed already-completed cards because ensure_work_card_unclaimed only checked lease status — settled work read as open backlog to every board reader, wasting persona cycles on finished work. New WorkCardNotClaimable error names the state and the explicit reopen path (airc work state). Test debt noted: the guard needs a board-fixture regression test when the work-board test harness grows one (none exists today for this seam). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(self-heal): proof-of-life revives a DEAD dial endpoint — live-beaconing peer no longer stuck at 0-connected (#240) The failure-counted eviction (5 consecutive dial failures → DEAD) could only be revived by a strictly-fresher endpoint ADVERTISEMENT (`endpoints_advertised_at_ms`), which is written only when a rendezvous import ALSO rewrites the peer's endpoint set (`set_endpoints_json`, gated on the beacon carrying endpoints + the monotonic guard). So a peer that is provably ALIVE — beaconing, `last_seen_ms` advancing on every registry import via `touch_last_seen` — but whose endpoint set is unchanged (or whose stamp write is skipped for a cycle) would keep producing `PeerDialSkip{dead:true}` forever: its DEAD verdict never cleared, its route read 0-connected, and the only recovery was a manual `airc join`. That is exactly the field symptom in #240. Fix: recognize proof-of-life as a second DEAD-revival signal, parallel to the advert stamp. `DialQuarantine::gate` now also takes the peer's `last_seen_ms`; a DEAD endpoint is revived for ONE probe when the peer has been seen alive SINCE this streak's last failure (`proof_of_life_ms > entry.failed_at_ms`), even with no new advertisement. It is strictly more robust than the advert stamp (last_seen advances on every import; the advert stamp only when the endpoint set is also rewritten), and keeps the identical anti-noise contract: a failure under the fresher signal re-kills immediately (`record_failure` re-stamps `failed_at_ms`), so a live-but-unreachable endpoint costs at most one 3s dial per fresh beacon, never one per tick. Only the DEAD branch is affected — timed backoff and the never-failed path are unchanged. Threaded `last_seen_ms` from `StoredPeer` (collected monotonic-max alongside `advert_stamps` in `dial_stored_peer_endpoints`) through `dial_one_peer` → `dial_quarantine_gate` → `gate`, for both the LAN/Tailscale and relay dial arms. The honest send-receipt half of #240 was already fixed (#1243): `format_send_receipt` loudly reports "reached 0 of N enrolled peer(s): NONE currently connected" when the daemon holds no live route. This closes the self-heal half so that receipt stops being the steady state for a peer that is actually reachable. Tests: new `fresh_proof_of_life_revives_a_dead_endpoint_without_a_new_advertisement` unit test pins the revival + one-probe-per-beacon re-kill; all 325 airc-lib unit tests + 13 stored_endpoint_dial integration tests stay green; fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * style: cargo fmt work.rs — WorkCardNotClaimable literal left unformatted by 5546292 Picked up by `cargo fmt` while formatting the #240 change; keeps the pre-push fmt gate green. No logic change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…dpoints NOW (#240 event-driven heal) (#1294) feat(self-heal): registry import nudges route-refresh — dial fresh endpoints NOW, not up to an interval later (#240 event-driven heal) Follow-up to the proof-of-life quarantine revival (#1293). That fix made a live peer's DEAD endpoint eligible to dial again; this closes the SCHEDULING gap after it. Two loops drive reconnection: the 60s route-refresh loop (dials stored endpoints) and the ~120s account-registry loop (imports fresh beacons/endpoints from the rendezvous into the trust store). They were decoupled — a registry import could land a disconnected peer's fresh endpoint and then sit until the next route-refresh TICK fired, up to a full REFRESH_INTERVAL later. This adds the mirror of the existing `endpoint_resync` nudge (route→registry: "my IP moved, republish"). New `route_wake` nudge (registry→route): the registry loop notifies it after every tick that actually imports, and the route-refresh loop grows a `wake` select arm that runs a refresh IMMEDIATELY instead of waiting out the remaining interval. Freshly-advertised endpoints for a disconnected peer are dialed at once. Disciplined, not spammy: - Edge-triggered on a REAL import only — `gated_tick` now returns whether a publish+refresh ran and succeeded; a gate-skip or failed tick imports nothing, so no nudge. - The woken refresh is the SAME idempotent work — connected peers skipped, quarantine/ghost gates apply — so in steady state (all connected) it's a cheap no-op. Real work happens only when a reconnect is actually pending. - `Notify` stores one permit, so a nudge landing during a refresh is consumed by the next wait (never lost, coalesced). Wiring: `DaemonState.route_wake: Arc<Notify>` (shared by both loops, same clone as `endpoint_resync`); threaded into `route_refresh::run_periodic_refresh(shutdown, wake, ..)` and `registry_refresh::run_loop(.., resync, route_wake, shutdown)`. Tests: new `a_wake_nudge_runs_a_refresh_before_the_interval_elapses` (start_paused) pins that a nudge runs a refresh mid-interval, not at the next timer tick. All airc-daemon (8) + airc-lib (325) unit tests green; fmt + clippy --all-targets -D warnings clean. Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ct at once (#240 event-driven heal, peer-dropped leg) (#1295) feat(self-heal): a dropped LAN session nudges route-refresh — reconnect at once, not up to an interval later (#240 event-driven heal, peer-dropped leg) Third and final leg of the #240 event-driven heal, completing the symmetry: - peer CAME BACK: registry import nudges route-refresh (#1294) ✓ - peer DROPPED: this — a terminated live session nudges route-refresh Before this, when a live LAN session dropped (clean EOF, I/O error, or a malformed/oversized frame) the daemon only noticed on its next route-refresh tick — up to a full REFRESH_INTERVAL (60s) later. That is the "two trusted peers can't maintain a conversation" latency: a transient blip left the link dark for up to a minute even though the peer was still reachable. Now the transport fires a disconnect observer the instant a peer leaves the `connections` map, and the daemon turns that into a `route_wake` nudge, so a reconnection dial is attempted AT ONCE — quarantine-gated, so a genuinely offline peer still costs at most one dial while a transient drop reconnects in seconds. The wake permit coalesces a burst of drops (partition) into one refresh. Wiring (mirrors the existing `on_inbound` learned-IP observer end to end): - airc-transport: `Inner.on_disconnect` + `DisconnectObserver` type + `set_disconnect_observer`; a `disconnect()` helper removes the peer and fires the observer (lock released before the callback), called at all four session- termination sites in `read_loop`. - airc-lib: `AircInner.on_disconnect` SLOT (so registration order is irrelevant — the adapter's observer, wired once at lazy adapter creation, reads the slot each drop) + `Airc::set_disconnect_observer`. - airc-cli daemon boot: register `move |_peer| route_wake.notify_one()` on the shared daemon handle, right beside the route-refresh spawn. Tests: new `disconnect_removes_the_peer_and_fires_the_observer` pins the remove + observer-fire contract. airc-transport (33) + airc-lib (325) + airc-daemon (8) unit tests green; fmt + clippy --all-targets -D warnings clean. Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…t strike — self-heal when a peer returns under a new identity (#240 follow-up) (#1296) fix(route): a cert-identity-mismatch dial evicts the orphan on the FIRST strike, not the fifth — self-heal when a peer returns under a new identity (#240 follow-up) Glass-boxed 2026-07-28 on the M5↔bigmama grid: bigmama re-installed and came back under a NEW peer identity, so her old advertised relay endpoint now presents a cert for a different peer. Every dial to it failed forever with `server cert is for peer 71dcc50f, expected 2f0aed7f`. The survivor (M5) stayed "stuck on the orphan" — it kept hammering the dead identity and the grid wedged; recovery needed a manual reconnect on bigmama's side. This is the "one node goes down and comes back, the other stuck on the orphan" reliability gap #240 didn't cover. Root cause: a cert-identity-mismatch is DETERMINISTIC — the endpoint's identity is provably wrong, so retrying the SAME stored endpoint can never succeed — but the dial loop recorded it as a TRANSIENT failure, so it took `DEAD_AFTER_CONSECUTIVE_FAILURES` (5) strikes across ~5 refresh cycles to evict, and the relay path had no mismatch handling at all. Fix: - `DialQuarantine::record_terminal_failure` — jumps the streak straight to the DEAD threshold so `gate` evicts on the first strike. It changes ONLY the strike count: the revival conditions are untouched, so a FRESHER advertisement (the returning peer's new endpoint) or a proof-of-life still lifts the eviction. The returning peer reconnects on its own — no manual `airc join`. - `discovery.rs`: both the direct-dial path (after the identity self-heal retry fails to adopt the presented identity) and the relay-dial path now classify a mismatch verdict (`presented_peer_from_mismatch_error`) as terminal and evict immediately; a transient failure still counts normally. Note the common orphan case is even cleaner: the returning peer's NEW identity is a NEW quarantine key with no entry → dials immediately; the terminal eviction just stops the survivor wasting cycles on the dead OLD identity. Validated: `cargo test -p airc-lib dial_quarantine` (11 passed incl. new `terminal_failure_is_dead_on_the_first_strike_but_still_revives`); airc-lib fmt + clippy (deny-warnings) clean. NOT yet end-to-end validated on a live 2-node down/up — that needs a coordinated bigmama reconnect; the unit test pins the eviction+revival logic. Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ival — kill the 0↔1 route flap (self-heal #2) Glass-boxed live on the M5↔bigmama grid 2026-07-28: transport health oscillated 0↔1 healthy route every ~20s. Root cause: record_terminal_failure (the cert-identity-mismatch path, #1296) evicts the orphan endpoint on the first strike but left BOTH revival signals live — a fresher advert OR proof-of-life. Proof-of-life revival is wrong for a DETERMINISTIC failure: the peer being alive changes nothing about a cert that mismatches forever on the SAME stored endpoint. So every presence beacon (last_seen_ms advances each registry import) revived the corpse → re-dial → re-mismatch → re-kill → next beacon revives again. That IS the flap ('cert is for 71dcc50f, expected 2f0aed7f' cycling). Fix: mark terminal entries `terminal: true` on QuarantineEntry; gate()'s alive_since_failure is now `!entry.terminal && proof_of_life_ms > failed_at_ms`. A terminal eviction revives SOLELY on a genuinely fresher endpoint ADVERTISEMENT (new addr/port/cert) — the only thing that can actually clear a deterministic cert mismatch. Non-terminal (transient) failures keep both signals, preserving the #240 live-peer heal untouched. Tests: renamed the terminal test to _revives_only_on_fresher_advert (drops the now- wrong proof-of-life-revives assertion); added terminal_eviction_is_not_revived_by_ proof_of_life_no_flap pinning the flap regression (advancing PoL stays DEAD every tick; only a fresher advert lifts it). #240 non-terminal PoL-heal test stays green. 12 dial_quarantine + 44 route tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ve streaming, throttled 1/s The seam summary was the ONLY advance a consumer ever saw: during live streaming the watermark never moved, so a cursor-persisting consumer (continuum #261, found by its PR #2057 review) re-received the WHOLE session's events on every reattach — five reboots of full-session redelivery produced a persona echo storm on 2026-07-30. After each forwarded Event the daemon now emits an advance frame carrying that event's cursor, throttled to one per second (kinds:None attaches carry StreamChunk bursts — never a frame storm). The unadvanced tail at shutdown shrinks from the whole session to ≤1s of events. skipped=0 on heartbeats (nothing suppressed). Contract change, test re-pinned: AttachCursorAdvanced may interleave on ANY attach shape; clients must tolerate it. The no-skip property the test now asserts: an advance NEVER precedes the delivery of the event it points at — persisting advanced_to can only resume at-or-before what was seen. attach_backlog_coalesce 3/3, airc-daemon suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…nest doctor on zero routes Three layers of the 'airc always broken' shape, glass-boxed live today (peers dialing relay :65280 while the live listener sat on :57958, Connection refused, 0 healthy routes — and doctor stamping [ok] on it): 1. doctor: 'degraded == 0' was vacuously true for an EMPTY health list — zero routes with enrolled remote peers now WARNS 'remote delivery is DOWN' instead of [ok] 0 route(s) healthy. 2. dial: a relay endpoint pinning THIS node's peer id is rewritten onto the LIVE relay listener port before dialing — the node is the authority on its own listener; it must never dial (then quarantine) a previous incarnation's dead port. 3. election: become_relay_with_stable_port persists the bound port in the runtime dir and re-binds it on restart, so every peer's imported airc-relay://me@ip:port card stays valid across daemon restarts — OS-assigned is only the first-election / port-stolen fallback. Tests: self-relay substitution (live port wins / foreign relay dials as recorded) + bind-candidate order (persisted first, no double-zero). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
|
Live-verify from M5 after deploy: (1) stable-port fix confirmed — new daemon re-bound the seeded :65280, exactly where the mesh's stale cards point; (2) doctor-honesty fix confirmed — '[WARN] route health: 0 routes with 43 enrolled peer(s) — remote delivery is DOWN' replaces the old vacuous [ok]. Known follow-up: the dial-time self-substitution guards on relay_peer == self identity, but the relay is owned by the MACHINE daemon identity while scope instances dial with their own — so a stale advert from an intermediate incarnation (:59583) isn't rewritten and must wait one republish+re-read cadence to converge (a one-time cost now that the port is stable). Widening the guard to same-machine detection (relay addr ∈ local IPs) is the follow-up slice. |
…howing (#270) The day's fourth silent-drop layer was pure presentation: bare 'airc room' printed only the CURRENT room (read as a membership list) and 'airc inbox' silently paged only the current room — so a subscribed-but-not-current channel's messages sat invisible while both agents diagnosed a transport failure that did not exist. Membership must be visible to be trusted: room now lists every subscription with the current one marked; inbox headlines 'room X ONLY — N other subscribed room(s) NOT shown: ...'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… APIs The #270 rename to 'current:' broke continuum-core's airc bootstrap, which parses `airc room` stdout for the 'room: <name>' line (degraded probe on next boot). Restore the stable label; the subscription list stays beneath. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ks /^channel:/ Second downstream parser of the same human output (continuum's start-server.sh derives AIRC_DEFAULT_CHANNEL via awk anchored at line start); the #270 indent broke it and fail-loud full-citizen boot refused. Exact legacy shape restored; subscription list still appended after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…t the CLI scope The five-month 'airc is broken' pattern was mostly this class: short-lived CLI probes building their own scope-local view of state the machine daemon owns. 'endpoints: none' printed while the daemon listened on two ports with live inbound connections — a lie of omission that misdirected every transport debug toward routes that were fine. Now: ask the daemon (Request::RouteEndpoints, the op card 4b6a0ffa already built for exactly this reason); print each advertised endpoint; empty-from-daemon says loudly 'NOT dialable'; daemon-unreachable falls back to the scope view LABELED non-authoritative. Verified live: daemon-advertised LanTcp 192.168.1.249:57958 now visible where 'none' printed before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…elay on every tick Regression glass-boxed 2026-07-31 morning: overnight daemon restart dropped the :65280 relay listener because self-election only fires when NO peer is reachable — and a direct LAN peer was connected. But peers hold airc-relay://me@ip:port cards; a relay that only exists during total isolation refuses every card-holder the rest of the time. The persisted relay-port file IS the role record: when it exists, become_relay runs every tick unconditionally (idempotent — already-relaying is a cheap re-advertise). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…nt the live feed
Glass-boxed 2026-07-31 after a full day of it: a multi-paragraph room
message printed its body raw, so every line-tailing consumer (agent
monitors, Codex hooks, grep pipelines, both grid agents' feeds) saw one
attributed '[Message] a -> b: <first line>' and then a storm of orphan
fragments ('1. **', '- Investigate', 'Let') with no sender, no channel,
no kind — hours of unattributable noise per message, on BOTH nodes
('the version-drift rendering'). render_feed_line now flattens newlines
to a visible pilcrow so the line contract holds unconditionally;
regression test pins it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…s assertion Two CI-only reds on #1302, both genuinely ours: - clippy (-D clippy::expect-used): become_relay_with_stable_port ended in last_err.expect(). Restructured so the OS-assigned bind (port 0 — always the terminal candidate, pinned by the relay_bind_candidates tests) is the final attempt whose error propagates via ?. No Option, no panic path. - transport_health_reports_no_routes_on_fresh_scope asserted the OLD scope-local 'endpoints: none' literal — exactly the line this PR replaced with daemon-authoritative rendering. A fresh scope now prints one of two honest states (daemon advertising nothing / daemon unreachable, labeled scope-local) depending on whether the environment auto-spawned the scope daemon; the test accepts both and still rejects the killed lie. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
Fixes the three layers of today's live mesh outage (glass-boxed: every peer dialing relay :65280 while the live listener sat on :57958 — Connection refused, 0 healthy routes, and doctor stamping [ok] on all of it):
degraded == 0was vacuously true for an EMPTY health list. Zero routes with enrolled remote peers now WARNS "remote delivery is DOWN" instead of[ok] 0 route(s) healthy.become_relay_with_stable_portpersists the bound port in the runtime dir (relay-port) and re-binds it on restart, so every peer's importedairc-relay://me@ip:portcard stays valid across daemon restarts. OS-assigned is only the first-election / port-stolen fallback, and whatever binds gets persisted.Tests: self-relay substitution (live port wins / foreign relay dials as recorded) + bind-candidate order (persisted first, no double-zero). Deployed live on M5: relay-port seeded 65280 so the mesh's existing stale cards heal without any peer-side update.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo