Skip to content

Commit 150df97

Browse files
authored
Merge pull request #560 from intendednull/claude/friendly-maxwell-UlJEd
auto-fix batch claude/friendly-maxwell-UlJEd 2026-05-03
2 parents 677a50e + 5320c8e commit 150df97

14 files changed

Lines changed: 282 additions & 44 deletions

File tree

.claude/skills/resolving-issues/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ Why this shape:
5959
- if closed issue is a sub-issue of a parent, run parent close-out check (see ## Parent issue close-out) — close parent too if this was its last open child
6060

6161
Closing GH issues = metadata work, not code work; allowed under "coordinator never codes." Implementers only dispatch for *unresolved* issues. This pass typically clears audit lessons (already folded into skills), structural-deps follow-ups (still structural), and audit findings closed by intervening fixes — saves dispatch overhead on no-op work.
62+
63+
**When same-day audit-to-fix gap, expect ~zero already-fixed hits.** The sweep yields most when the audit ran days/weeks before the fix run (intervening PRs land between audit and fix). When the audit was filed earlier the same day against the current `main`, no fixes can have landed between audit and fix run by definition — the sweep correctly returns empty. Don't over-invest sweep effort in this case; one quick `git log <audit-ref>..origin/main` check on each pick's path is enough to confirm.
64+
65+
**Ambiguous-fix-path (audit premise real, fix is a design call) — coordinator-skip without close.** Some audit findings flag a real concern but the prescribed fix is ambiguous-by-design with ≥ 2 legitimate approaches that need a design decision the coordinator can't make unilaterally. Common shape: "audit-glob doesn't catch in-file `mod tests` — move to external test file OR update glob." Both are valid; picking either silently is wrong. **Action:** during step 6 picks, if the audit's literal fix conflicts with HEAD reality (e.g. code already satisfies the surface premise) AND the underlying concern requires a design call, **skip the issue from this run's dispatch queue without closing it**. Note it in the run-end `## Lessons Learned` so the next session has full context. Don't comment on the issue — the audit description already captures the concern; a "skipped, design call needed" comment adds noise without progress. The next run with fresh eyes / accumulated context can decide.
6266
7. Per remaining issue, sequential, max 10 per run:
6367
- **Pre-dispatch sync:** before spawning each implementer, in the coordinator's checkout:
6468
```bash

.github/workflows/deploy.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ jobs:
7272
7373
- name: Verify deployment
7474
run: |
75-
sleep 3
76-
curl -sf -o /dev/null -w "Web: HTTP %{http_code}\n" https://willow.intendednull.com/
75+
# Poll the web endpoint until it serves (max ~30s) instead of a
76+
# blind 3s sleep — avoids flaking when the relay takes longer to
77+
# bind and masking a silent deploy failure.
78+
for i in $(seq 1 15); do
79+
if curl -sf -o /dev/null -w "Web: HTTP %{http_code}\n" https://willow.intendednull.com/; then
80+
break
81+
fi
82+
if [ "$i" -eq 15 ]; then
83+
echo "verify: HTTP probe never succeeded after 15 attempts (~30s)" >&2
84+
exit 1
85+
fi
86+
sleep 2
87+
done
7788
sshpass -p '${{ secrets.DEPLOY_PASSWORD }}' ssh -o StrictHostKeyChecking=no root@willow.intendednull.com 'systemctl is-active willow-relay'

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ All shared state derived from per-author Merkle DAG of signed events. `willow-st
342342
- **EventDag** (`crates/state/src/dag.rs`): in-memory store of all known events, indexed by `EventHash`. `EventDag::insert` validates signature, genesis, `prev`/`deps` linkage. No explicit "merge" — DAG converges as events arrive.
343343
- **ServerState** (`crates/state/src/server.rs`): materialized state derived by walking the DAG.
344344
- **`materialize::apply_incremental(state, event)`**: ONLY public mutation entry point. Pure. Internally calls `apply_event` + `required_permission` for authority checks.
345-
- **Permission model**: Owner = root of trust. Fine-grained permissions (SyncProvider, ManageChannels, ManageRoles, KickMembers, SendMessages, CreateInvite, Administrator) granted via `GrantPermission` events.
345+
- **Permission model**: Owner = root of trust. Fine-grained permissions (SyncProvider, ManageChannels, ManageRoles, SendMessages, CreateInvite) granted via `GrantPermission` events. Admin status is structurally separate — managed exclusively through `ProposedAction` + vote path, never via `GrantPermission`. Kicks are an admin-only `ProposedAction` (no granular "can kick" permission).
346346
- **Sync** (`crates/state/src/sync.rs`): `HeadsSummary` = compact per-author DAG state for efficient sync; `PendingBuffer` holds events arriving before their `prev` chain predecessors.
347347

348348
### Trust Model

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/client/src/worker_cache.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,15 @@ impl WorkerCache {
5858

5959
/// Evict workers that haven't sent a heartbeat within the TTL.
6060
pub fn evict_stale(&mut self) {
61-
let cutoff = Instant::now() - self.ttl;
61+
self.evict_stale_at(Instant::now());
62+
}
63+
64+
/// Evict workers stale relative to an injected `now`.
65+
///
66+
/// Used by tests to remove timing dependencies; production callers should
67+
/// use [`Self::evict_stale`].
68+
pub(crate) fn evict_stale_at(&mut self, now: Instant) {
69+
let cutoff = now - self.ttl;
6270
self.workers.retain(|_, info| info.last_seen > cutoff);
6371
}
6472

@@ -171,10 +179,11 @@ mod tests {
171179

172180
#[test]
173181
fn evict_stale_removes_expired() {
174-
let mut cache = WorkerCache::new(Duration::from_millis(1));
182+
let mut cache = WorkerCache::new(Duration::from_secs(30));
175183
cache.update(&make_replay_announcement(gen_id(), vec!["srv-1"]));
176-
std::thread::sleep(Duration::from_millis(10));
177-
cache.evict_stale();
184+
// Simulate "now" being well past the TTL boundary instead of sleeping.
185+
let future = Instant::now() + Duration::from_secs(60);
186+
cache.evict_stale_at(future);
178187
assert!(cache.is_empty());
179188
}
180189

crates/common/src/lib.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,21 @@ pub use worker_types::*;
2424
/// `willow-common` (already a dep of both crates) guarantees they stay
2525
/// aligned at compile time.
2626
pub const SYNC_BATCH_LIMIT: usize = 10_000;
27+
28+
/// Maximum number of `(author, head)` entries accepted from a peer-supplied
29+
/// [`willow_state::HeadsSummary`] in a single sync / history call.
30+
///
31+
/// Single source of truth shared by:
32+
/// - `willow-storage` — `sync_since` / `history` build SQL by concatenating
33+
/// one `(author = ? AND seq <op> ?)` fragment per entry; an oversize
34+
/// summary would either exceed rusqlite's bind-parameter limit
35+
/// (default 32766) or waste CPU compiling a giant prepared statement.
36+
/// - `willow-replay` — `handle_request(Sync)` iterates per-author into a
37+
/// `BTreeMap` then walks the in-memory DAG; an oversize summary forces
38+
/// O(N) work and is the same DoS shape as the storage path.
39+
///
40+
/// 256 is well above any plausible honest server's distinct-author count
41+
/// while keeping bind-parameter and BTreeMap-construction costs bounded.
42+
/// Both production sites MUST agree on this value, so the canonical
43+
/// definition lives here alongside [`SYNC_BATCH_LIMIT`].
44+
pub const MAX_AUTHORS_PER_SYNC: usize = 256;

crates/identity/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,4 @@ getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] }
2020
getrandom_03 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
2121

2222
[dev-dependencies]
23-
tokio = { workspace = true }
2423
tempfile = "3"

crates/replay/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ willow-worker = { path = "../worker" }
1212
willow-state = { path = "../state" }
1313
willow-identity = { path = "../identity" }
1414
willow-network = { path = "../network" }
15+
willow-common = { path = "../common" }
1516
clap = { version = "4", features = ["derive"] }
1617
dirs = "6"
1718
anyhow = { workspace = true }

crates/replay/src/role.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use std::collections::{BTreeMap, HashMap};
88

99
use tracing::warn;
10+
use willow_common::MAX_AUTHORS_PER_SYNC;
1011
use willow_state::{
1112
apply_incremental, Event, EventDag, EventHash, EventKind, HeadsSummary, InsertError,
1213
PendingBuffer, ServerState, Snapshot, DEFAULT_PENDING_MAX_AGE_MS, DEFAULT_PENDING_MAX_ENTRIES,
@@ -273,6 +274,21 @@ impl WorkerRole for ReplayRole {
273274
fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse {
274275
match req {
275276
WorkerRequest::Sync { server_id, heads } => {
277+
// Reject peer-supplied summaries that would force O(N)
278+
// BTreeMap construction and DAG walks (see
279+
// `MAX_AUTHORS_PER_SYNC`). Mirrors the storage cap added in
280+
// PR #507 / b075140; gated before any allocation so a hostile
281+
// request fails fast.
282+
if heads.heads.len() > MAX_AUTHORS_PER_SYNC {
283+
return WorkerResponse::Denied {
284+
reason: format!(
285+
"too many heads in sync request: {} > {}",
286+
heads.heads.len(),
287+
MAX_AUTHORS_PER_SYNC
288+
),
289+
};
290+
}
291+
276292
let data = match self.servers.get(&server_id) {
277293
Some(d) => d,
278294
None => {
@@ -1458,6 +1474,80 @@ mod tests {
14581474
}
14591475
}
14601476

1477+
// ── Issue #514: oversize HeadsSummary rejection ──────────────────────
1478+
//
1479+
// Mirrors the storage cap added by PR #507 / b075140. Without a guard
1480+
// here, a malicious peer could send a multi-thousand-entry HeadsSummary
1481+
// and force replay to do per-author BTreeMap inserts and DAG walks for
1482+
// every entry — same DoS shape as the storage path the sibling PR fixed.
1483+
1484+
/// Build a `HeadsSummary` with `n` distinct random authors. Mirrors the
1485+
/// helper in `crates/storage/src/store.rs` (sibling cap test infra).
1486+
fn heads_summary_with_authors(n: usize) -> HeadsSummary {
1487+
use willow_state::AuthorHead;
1488+
let mut heads = BTreeMap::new();
1489+
for _ in 0..n {
1490+
let id = Identity::generate();
1491+
heads.insert(
1492+
id.endpoint_id(),
1493+
AuthorHead {
1494+
seq: 1,
1495+
hash: EventHash::ZERO,
1496+
},
1497+
);
1498+
}
1499+
HeadsSummary { heads }
1500+
}
1501+
1502+
/// A peer-supplied `HeadsSummary` with more than `MAX_AUTHORS_PER_SYNC`
1503+
/// entries must be rejected by `handle_request(Sync)` before any
1504+
/// per-author BTreeMap construction or DAG walk occurs.
1505+
#[test]
1506+
fn sync_request_rejects_oversize_heads() {
1507+
use willow_common::MAX_AUTHORS_PER_SYNC;
1508+
let mut role = ReplayRole::new(ReplayConfig::default());
1509+
let (_, _) = setup_server(&mut role, "srv-1");
1510+
1511+
let oversize = heads_summary_with_authors(MAX_AUTHORS_PER_SYNC + 1);
1512+
let resp = role.handle_request(WorkerRequest::Sync {
1513+
server_id: "srv-1".to_string(),
1514+
heads: oversize,
1515+
});
1516+
1517+
match resp {
1518+
WorkerResponse::Denied { reason } => {
1519+
assert!(
1520+
reason.contains("too many heads"),
1521+
"denial reason should mention the cap; got: {reason}"
1522+
);
1523+
}
1524+
other => panic!("expected Denied for oversize heads, got: {other:?}"),
1525+
}
1526+
}
1527+
1528+
/// `handle_request(Sync)` must accept exactly `MAX_AUTHORS_PER_SYNC`
1529+
/// entries — the cap is inclusive on the legal side.
1530+
#[test]
1531+
fn sync_request_accepts_exact_cap_heads() {
1532+
use willow_common::MAX_AUTHORS_PER_SYNC;
1533+
let mut role = ReplayRole::new(ReplayConfig::default());
1534+
let (_, _) = setup_server(&mut role, "srv-1");
1535+
1536+
let at_cap = heads_summary_with_authors(MAX_AUTHORS_PER_SYNC);
1537+
let resp = role.handle_request(WorkerRequest::Sync {
1538+
server_id: "srv-1".to_string(),
1539+
heads: at_cap,
1540+
});
1541+
1542+
// The peer's heads mention authors we don't know, so events_since
1543+
// returns the genesis event; our store has 1 author the peer doesn't,
1544+
// so they_are_behind is true. Either branch is acceptable — the only
1545+
// forbidden outcome is Denied for at-cap input.
1546+
if let WorkerResponse::Denied { reason } = resp {
1547+
panic!("at-cap heads must not be denied; got reason: {reason}");
1548+
}
1549+
}
1550+
14611551
/// When the configured `pending_max_entries` is exceeded, the oldest
14621552
/// entries are evicted and `pending_count()` reflects the cap.
14631553
#[test]

crates/storage/src/store.rs

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,9 @@
2020
//! never reordered or rewritten so existing databases stay consistent.
2121
2222
use rusqlite::{params, Connection};
23-
use willow_common::SYNC_BATCH_LIMIT;
23+
use willow_common::{MAX_AUTHORS_PER_SYNC, SYNC_BATCH_LIMIT};
2424
use willow_state::{Event, EventKind, HeadsSummary};
2525

26-
/// Maximum number of `(author, head)` entries accepted from a peer-supplied
27-
/// [`HeadsSummary`] in a single `sync_since` / `history` call.
28-
///
29-
/// `sync_since` and `history` build their SQL clauses by concatenating one
30-
/// `(author = ? AND seq <op> ?)` fragment per entry. A peer-supplied summary
31-
/// with thousands of entries (still well within the transport envelope cap)
32-
/// would either exceed rusqlite's bind-parameter limit (default 32766) or
33-
/// waste CPU compiling a giant prepared statement.
34-
///
35-
/// 256 is well above any plausible honest server's distinct-author count
36-
/// while keeping the bind-parameter count (~512 per call) and the SQL
37-
/// expression-tree depth safely below SQLite's defaults
38-
/// (32766 binds, depth 1000). Requests over the cap are rejected at the store layer;
39-
/// the caller in `role.rs` maps the error to a `WorkerResponse::Denied`.
40-
pub const MAX_AUTHORS_PER_SYNC: usize = 256;
41-
4226
/// Ordered list of schema migrations. Each entry is run inside its own
4327
/// transaction the first time the database is opened. Once a migration is
4428
/// shipped, never edit or reorder it — only append new entries.

0 commit comments

Comments
 (0)