Skip to content

fix(route): #267 — stable relay port + self-relay live-port dial + honest doctor on zero routes - #1301

Open
joelteply wants to merge 179 commits into
mainfrom
fix/relay-port-stability-267
Open

fix(route): #267 — stable relay port + self-relay live-port dial + honest doctor on zero routes#1301
joelteply wants to merge 179 commits into
mainfrom
fix/relay-port-stability-267

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

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):

  1. doctor honesty: 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. self-relay live-port dial: a relay endpoint pinning THIS node's peer id is rewritten onto the live relay listener's port before dialing — the node is the authority on its own listener and must never dial (then quarantine) a previous incarnation's dead port.
  3. stable relay port: become_relay_with_stable_port persists the bound port in the runtime dir (relay-port) 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, 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

joelteply and others added 30 commits May 13, 2026 11:25
)

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.
fix queue close-merged title refs
feat queue heartbeat and stale scan
feat add embeddable queue widgets
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.
joelteply and others added 21 commits June 20, 2026 18:13
…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
@joelteply

Copy link
Copy Markdown
Contributor Author

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.

joelteply and others added 5 commits July 30, 2026 17:43
…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
joelteply and others added 2 commits July 31, 2026 13:46
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant