Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/resolving-issues/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ Fresh agent per issue, scoped to one issue + master branch ref. Steps:

8. **Commit + push.** Use `caveman:caveman-commit` for the message. Conventional Commits format. `Refs #N` (NOT `Fixes` — that lives only on the master PR). Push directly to origin master branch.

**Never push `wip:` / `chore: checkpoint` / "work-in-progress" commits to the master branch.** Make all your edits, run the local gate, then commit ONCE with the proper Conventional Commits message. Coordinator's master PR body assembles `Fixes #N` rows from per-commit messages — `wip:` rows look like junk to a human reviewer and force a finalize-implementer detour to squash + force-push (real cost: ~10–15 min of cargo lock contention while the rescue agent re-runs gates, plus a `--force-with-lease` against a branch that may have already accumulated more commits from later dispatches if the coordinator misjudges sequencing). If you genuinely need intermediate checkpoints (long sessions, sandbox interference per next bullet), make them in a Pattern B local feature branch and squash via `git merge --no-ff` when done — never push intermediate commits to master.

**Sandbox `git reset --hard origin/<branch>` interference (known hazard).** Some sandboxed environments run a periodic `git reset --hard origin/<branch>` between tool invocations that silently rolls back uncommitted edits — visible as `Edit`/`Write` results vanishing between cargo commands, or as the working tree being clean when you expected staged changes. Detection: run `git status` after a tool call you expected to leave changes; if it's clean and the file content matches origin, the sandbox wiped it. Recovery: apply edits and `git add -A && git commit` in a tight single-shell pipeline (one `bash -c` invocation) before the next tool call lands. If you accumulate commits-as-checkpoints this way, follow the no-wip-commits rule above by squashing at the end via `git reset --soft <pre-dispatch-SHA> && git commit -m "<final message>" && git push --force-with-lease`. Note the sandbox-interference workaround in the commit body so the human can audit.

9. **Mid-fix block** (CI red on the local gate that won't resolve, brainstorm reveals deeper structural issue, fix demands cross-cutting refactor): **abort the dispatch.** `git checkout <master-branch>` + `git reset --hard origin/<master-branch>` to drop any local work. File a follow-up GH issue (caveman body, link original + cite the blocker). Return to coordinator. The follow-up issue is the durable handoff for the next scheduled run.

10. **Already-fixed-upstream path:** if pre-flight investigation (e.g. `cargo audit`, file-state grep, `cargo tree`) shows the issue was resolved by a recently-merged upstream PR, do NOT make a no-op commit. Leave a caveman comment on the original issue naming the upstream PR + the fix location, close the issue (`completed` if the audit's intent now holds — the upstream fix solved it for us; `not_planned` if the audit's premise is moot — e.g. the targeted code was deleted), report back. Coordinator records under `## Already-Fixed` in the master PR — NOT under `Fixes`.
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,6 @@ jobs:
--ignore RUSTSEC-2025-0141 `# bincode 1.x unmaintained (#247)` \
--ignore RUSTSEC-2024-0436 `# paste unmaintained, via leptos+iroh (#316)` \
--ignore RUSTSEC-2024-0370 `# proc-macro-error unmaintained, via iroh-blobs->genawaiter (#317)` \
--ignore RUSTSEC-2023-0089 `# atomic-polyfill unmaintained, via iroh->postcard->heapless (#318)`
--ignore RUSTSEC-2023-0089 `# atomic-polyfill unmaintained, via iroh->postcard->heapless (#318)` \
--ignore RUSTSEC-2026-0119 `# hickory-proto O(n^2) name compression, via iroh-relay (#508)` \
--ignore RUSTSEC-2026-0120 `# hickory-net NSEC3 unbounded loop, via iroh-relay (#509)`
1 change: 1 addition & 0 deletions crates/client/src/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ impl<N: willow_network::Network> ClientHandle<N> {
event_broker: self.event_broker.clone(),
identity: self.identity.clone(),
join_links: Arc::clone(&self.join_links),
pending_joins: Arc::clone(&self.pending_joins),
dag: self.dag_addr.clone(),
server_registry: self.server_registry_addr.clone(),
on_neighbor_up: None,
Expand Down
18 changes: 17 additions & 1 deletion crates/client/src/joining.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,23 @@ impl<N: willow_network::Network> ClientHandle<N> {
self.mutation_handle.broadcast_on_topic(topic, data);
}

pub fn send_join_request(&self, link_id: &str) {
/// Broadcast a `JoinRequest` to the inviter for `link_id` and
/// record the pending attempt so subsequent `JoinResponse` /
/// `JoinDenied` messages can be authenticated against the expected
/// `inviter_peer_id`.
///
/// `inviter_peer_id` is taken straight from the
/// [`ops::JoinToken::inviter_peer_id`] the caller decoded; the
/// listener uses it to drop spoofed responses signed by anyone else
/// (issue #309 / SEC-A-07).
pub fn send_join_request(&self, link_id: &str, inviter_peer_id: willow_identity::EndpointId) {
// Record the pending attempt BEFORE broadcasting. If the inviter
// races us and replies before we've populated the map, the
// listener would otherwise drop the legitimate response with a
// "no outstanding join request" debug log.
self.pending_joins
.lock()
.insert(link_id.to_string(), inviter_peer_id);
let msg = ops::WireMessage::JoinRequest {
link_id: link_id.to_string(),
peer_id: self.identity.endpoint_id(),
Expand Down
24 changes: 24 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,25 @@ pub struct ClientHandle<N: willow_network::Network> {
// docs/specs/2026-04-26-state-management-model-design.md § 4 and § F4.
// Single guard; deferred to keep this PR scoped.
pub(crate) join_links: Arc<parking_lot::Mutex<Vec<ops::JoinLink>>>,
/// In-flight join attempts initiated by this client, keyed by the
/// `link_id` of the outgoing [`ops::WireMessage::JoinRequest`]. The
/// value is the `EndpointId` of the inviter the request was sent to
/// (extracted from the `JoinToken`). Listeners use this map to verify
/// that incoming `JoinResponse` / `JoinDenied` messages were signed
/// by the expected inviter — without it, any signer with a guessed
/// `target_peer` could spoof a denial or trigger redundant decryption
/// work on the requester (see issue #309 / SEC-A-07).
///
/// Entries are inserted by `send_join_request` and removed when a
/// matching response/denial arrives. Stale entries persist until the
/// process restarts; the worst case is a small bounded leak
/// proportional to the number of join links a user clicks without
/// ever receiving a reply.
// state: lock-ok — same rationale as `join_links`; tiny map, rarely
// mutated, actor migration tracked alongside `join_links` in
// docs/specs/2026-04-26-state-management-model-design.md § F4.
pub(crate) pending_joins:
Arc<parking_lot::Mutex<std::collections::HashMap<String, willow_identity::EndpointId>>>,
/// Bootstrap peers for gossip topic subscriptions.
pub bootstrap_peers: Vec<willow_identity::EndpointId>,
/// The per-author Merkle-DAG actor — source of truth for all events.
Expand Down Expand Up @@ -340,6 +359,7 @@ impl<N: willow_network::Network> Clone for ClientHandle<N> {
persistence_addr: self.persistence_addr.clone(),
persistence_enabled: self.persistence_enabled,
join_links: Arc::clone(&self.join_links),
pending_joins: Arc::clone(&self.pending_joins),
bootstrap_peers: self.bootstrap_peers.clone(),
dag_addr: self.dag_addr.clone(),
view_handle: self.view_handle.clone(),
Expand Down Expand Up @@ -774,6 +794,7 @@ impl<N: willow_network::Network> ClientHandle<N> {
));
let topics: Arc<RwLock<HashMap<String, N::Topic>>> = Arc::new(RwLock::new(HashMap::new()));
let join_links = Arc::new(parking_lot::Mutex::new(Vec::new()));
let pending_joins = Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new()));

// Build StateRefs for derived actor sources.
let event_ref = willow_actor::state::StateRef::from(&event_state_addr);
Expand Down Expand Up @@ -931,6 +952,7 @@ impl<N: willow_network::Network> ClientHandle<N> {
persistence_addr,
persistence_enabled,
join_links,
pending_joins,
bootstrap_peers: config.bootstrap_peers,
dag_addr: dag_addr.clone(),
view_handle,
Expand Down Expand Up @@ -1119,6 +1141,7 @@ pub fn test_client() -> (
>,
> = Arc::new(RwLock::new(HashMap::new()));
let join_links = Arc::new(parking_lot::Mutex::new(Vec::new()));
let pending_joins = Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new()));

// Build StateRefs and derived views.
let event_ref = willow_actor::state::StateRef::from(&event_state_addr);
Expand Down Expand Up @@ -1272,6 +1295,7 @@ pub fn test_client() -> (
persistence_addr,
persistence_enabled: false,
join_links,
pending_joins,
bootstrap_peers: vec![],
dag_addr: dag_addr.clone(),
view_handle,
Expand Down
Loading