Skip to content

Commit a7102e8

Browse files
alex-poorclaude
andcommitted
Fix D31: find a subject by its own key, not by a guessed depth
`pool::subject_topic` hashes the subject key and nothing else. `carry_share` uses it for every subject it carries, so two peers no longer have to agree about how big the pool is in order to meet. Buckets keep dividing unknown subjects among volunteers, which is the job D21 justified them for. Proven by mutation, and the signature matters more than the pass: `peers_that_disagree_about_pool_size_still_meet` forces one peer to carry as if the pool were tiny and the other as if it were large. With the subject topic they sync in 3 seconds. Without it, `carrier events: []` — sixty seconds and not one sync session. The peers never discover each other, so in the field this bug is silence rather than an error, which is why it has passed as flakiness. The old bucket topic is carried alongside during the transition: moving a subject's topic fails silently rather than loudly, and the live pool is a phone driving an insulin pump. Marked for deletion once nothing uses it. While both are carried, `broadcast` returns 2 — one operation to two rendezvous, not two sends. Noted at the seam and in D31, because a doubling in a diagnostic is what this project keeps misreading. diaswarm-net 47 passed, 0 failed, from cargo's own exit code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b3da139 commit a7102e8

4 files changed

Lines changed: 163 additions & 4 deletions

File tree

‎crates/diaswarm-net/src/pool.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,35 @@ fn bucket_score(peer_id: &str, bucket: u64) -> [u8; 32] {
131131
h.finalize().into()
132132
}
133133

134+
/// Where a subject's operations are synced: a topic derived from the subject
135+
/// alone.
136+
///
137+
/// **NO DEPTH, NOTHING TO DISAGREE ABOUT — WHICH IS THE ENTIRE POINT.**
138+
/// [`bucket_topic`] hashes in a `depth` that every peer estimates from its own
139+
/// view of the pool size, so two peers that have discovered different numbers
140+
/// of peers derive unrelated topics and never meet. Observed at three peers:
141+
/// publisher `(2,1)`, relay `(2,1)`, follower `(3,2)`. A granted reader that
142+
/// guesses wrong cannot find its own subject, which is a hard failure of the
143+
/// thing the project is for. See [D31](../../docs/decisions.md).
144+
///
145+
/// **THIS IS P2PANDA'S TOPIC MODEL USED PLAINLY**: a 32-byte identifier for a
146+
/// set of data. It takes that half of the model and not the other — upstream
147+
/// assumes the identifier is a *secret* and protects it with PSI, which suits a
148+
/// private document shared by a known group. Here the data is public ciphertext
149+
/// by design (D1), holding is not reading, and a topic anybody can compute is
150+
/// how a volunteer knows what to carry. Public ciphertext wants public
151+
/// rendezvous.
152+
///
153+
/// Buckets keep the job [D21](../../docs/decisions.md) justified them for:
154+
/// dividing *unknown* subjects among volunteers. Finding a subject you were
155+
/// granted is not that job.
156+
pub fn subject_topic(subject: &str) -> [u8; 32] {
157+
let mut h = Sha256::new();
158+
h.update(b"diaswarm-subject-v1");
159+
h.update(subject.to_ascii_lowercase().as_bytes());
160+
h.finalize().into()
161+
}
162+
134163
/// The gossip topic for a bucket, as 32 bytes.
135164
///
136165
/// Depth is mixed in so that a bucket at depth 6 and its child at depth 7 are

‎crates/diaswarm-net/src/share.rs‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,32 @@ pub async fn carry_share(
103103

104104
let before = replicator.carried().len();
105105
for subject in &wanted {
106-
let topic = crate::pool::bucket_topic(depth, crate::pool::bucket_of(subject, depth));
107-
let _ = replicator.carry(topic, subject).await;
106+
// **THE RENDEZVOUS, AND IT HAS NO PARAMETERS.** A subject you were
107+
// granted is known by key; finding it is not a sharding problem. See
108+
// [D31](../../docs/decisions.md) and `pool::subject_topic`.
109+
let _ = replicator.carry(crate::pool::subject_topic(subject), subject).await;
110+
111+
// **AND THE OLD BUCKET TOPIC, FOR AS LONG AS ANYTHING USES IT.**
112+
// Changing where a subject is synced is not a wire-format change that
113+
// fails loudly — two peers on different topics simply never meet, which
114+
// looks exactly like the network being quiet. The live pool is a phone
115+
// driving an insulin pump and a follower somebody reads, so the new
116+
// build joins both and nothing goes dark on upgrade.
117+
//
118+
// DELETE THIS once no peer on the old topic remains. It costs one extra
119+
// subscription per carried subject, and it carries the depth bug with
120+
// it: if this peer guesses a different depth from its neighbour, the
121+
// legacy topic misses and the subject topic above still meets.
122+
//
123+
// **AND IT DOUBLES `pushed`, WHICH IS NOT A FAULT.**
124+
// `Replicator::broadcast` returns the number of *topics* it published
125+
// on, so while both are carried a phone's `shadow agrees — … pushed 2`
126+
// is one operation sent to two rendezvous, not two sends of one
127+
// operation. It drops back to 1 when this block goes. Saying so here
128+
// because a doubling in a diagnostic is exactly the kind of thing this
129+
// project has spent days misreading.
130+
let legacy = crate::pool::bucket_topic(depth, crate::pool::bucket_of(subject, depth));
131+
let _ = replicator.carry(legacy, subject).await;
108132
}
109133
let after = replicator.carried();
110134

‎crates/diaswarm-net/tests/keys_pool.rs‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,77 @@ async fn a_stranger_carries_a_keys_subject_it_was_never_introduced_to() {
195195
one copy that anyone can find and D15 still does not hold"
196196
);
197197
}
198+
199+
/// PEERS THAT DISAGREE ABOUT THE POOL SIZE STILL MEET.
200+
///
201+
/// **THE BUG D31 RECORDS, WRITTEN AS A TEST.** `bucket_topic` hashes in a
202+
/// `depth` that each peer estimates from its own view of how many peers exist.
203+
/// Two peers that have discovered different numbers derive unrelated topics and
204+
/// never meet — observed live at three peers: publisher `(2,1)`, relay `(2,1)`,
205+
/// follower `(3,2)`. A granted reader that guesses wrong cannot find its own
206+
/// subject, and the symptom is silence.
207+
///
208+
/// Here the disagreement is forced rather than waited for: the publisher
209+
/// carries as if the pool were tiny and the follower as if it were large. On
210+
/// the bucket topic they would share nothing. On `subject_topic` there is no
211+
/// depth to disagree about, so they meet.
212+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
213+
async fn peers_that_disagree_about_pool_size_still_meet() {
214+
let net = network_id("keys-depth-disagreement");
215+
let rng = Rng::default();
216+
let days = 2i64;
217+
218+
let subject_key = SigningKey::generate();
219+
let author = subject_key.verifying_key();
220+
let subject_hex = author.to_hex();
221+
let mut subject = Vault::open(tmp("dd-subject"), OFFSET, &subject_key).unwrap();
222+
let (mgr, _bundle) = Vault::key_bundle(&rng).unwrap();
223+
224+
let subject_store = SqliteStoreBuilder::memory().build().await.unwrap();
225+
let create = subject.create(mgr).unwrap();
226+
wire::publish_control(&subject_store, &subject_key, &create).await.unwrap();
227+
for e in 0..days {
228+
let segment = subject.seal(26_000 + e, &day(26_000 + e, 100.0)).unwrap();
229+
wire::publish(&subject_store, &subject_key, &segment).await.unwrap();
230+
}
231+
232+
let pub_swarm = Swarm::join_network(tmp("dd-pub"), SigningKey::generate(), net).await.unwrap();
233+
let (pe, pg) = pub_swarm.parts();
234+
let publishing = KeysReplicator::keys(subject_store.clone(), pe, pg).await.unwrap();
235+
236+
let car_store = SqliteStoreBuilder::memory().build().await.unwrap();
237+
let car_swarm = Swarm::join_network(tmp("dd-car"), SigningKey::generate(), net).await.unwrap();
238+
let (ce, cg) = car_swarm.parts();
239+
let carrying = KeysReplicator::keys(car_store.clone(), ce, cg).await.unwrap();
240+
241+
// ---- the disagreement, made explicit -------------------------------
242+
//
243+
// Depth 1 is "I have seen two peers"; depth 9 is "I have seen hundreds".
244+
// These are the topics each side WOULD have used, and they share nothing.
245+
let as_if_small = pool::bucket_topic(1, pool::bucket_of(&subject_hex, 1));
246+
let as_if_large = pool::bucket_topic(9, pool::bucket_of(&subject_hex, 9));
247+
assert_ne!(as_if_small, as_if_large, "the premise of this test is a real disagreement");
248+
publishing.carry(as_if_small, &subject_hex).await.unwrap();
249+
carrying.carry(as_if_large, &subject_hex).await.unwrap();
250+
251+
// ---- and the topic that does not care ------------------------------
252+
let meeting = pool::subject_topic(&subject_hex);
253+
publishing.carry(meeting, &subject_hex).await.unwrap();
254+
carrying.carry(meeting, &subject_hex).await.unwrap();
255+
256+
let mut held = 0u32;
257+
for _ in 0..60 {
258+
tokio::time::sleep(Duration::from_secs(1)).await;
259+
held = segments_held(&car_store, &author).await;
260+
if held as i64 >= days {
261+
break;
262+
}
263+
}
264+
assert_eq!(
265+
held as i64, days,
266+
"two peers that disagreed about the pool size did not meet on the subject \
267+
topic either, so the depth is not the only thing keeping them apart \
268+
(carrier events: {:?})",
269+
carrying.events()
270+
);
271+
}

‎docs/decisions.md‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -936,8 +936,40 @@ every peer's estimate to land in the same bucket of `depth_for`, and there are
936936
more ways to disagree at 21 bits than at 1. The test's assertion now prints all
937937
three depths on failure, so the next occurrence names its own cause.
938938

939-
*Blocks:* honest claims about pool behaviour above a handful of peers, and it is
940-
a live intermittent defect below that.
939+
#### ✅ FIXED 2026-09-14 — `pool::subject_topic`, and the old topic kept beside it
940+
941+
`carry_share` now carries every subject at `subject_topic(subject)` — a hash of
942+
the subject key, no depth, nothing to disagree about. Buckets keep the job
943+
[D21](#d21--replication-is-p2panda-log-sync-over-the-pools-own-topics)
944+
justified them for: dividing *unknown* subjects among volunteers.
945+
946+
**Proven by mutation, and the failure signature is the part worth keeping.**
947+
`peers_that_disagree_about_pool_size_still_meet` forces the disagreement rather
948+
than waiting for it — one peer carries as if the pool were tiny (depth 1), the
949+
other as if it were large (depth 9). With the subject topic they sync in **3
950+
seconds**. With it removed:
951+
952+
```
953+
carrier events: [] — sixty seconds, no sync sessions at all
954+
```
955+
956+
Not a failed session, not a retry: the peers never discover each other. **In the
957+
field this bug is silence, not errors**, which is exactly why it has been read
958+
as flakiness for as long as it has existed.
959+
960+
**The old bucket topic is carried as well, for now.** Moving where a subject
961+
syncs is not a wire-format change that fails loudly — peers on different topics
962+
simply never meet. The live pool is a phone driving an insulin pump and a
963+
follower somebody reads, so the new build joins both. The block is marked for
964+
deletion once no peer remains on the old topic.
965+
966+
⚠️ **While both are carried, `pushed` reads 2 rather than 1.**
967+
`Replicator::broadcast` returns the number of *topics* it published on, so
968+
`shadow agrees — … pushed 2` is one operation sent to two rendezvous, not two
969+
sends. It returns to 1 when the legacy block goes.
970+
971+
*Still open:* the divergence in §2 is recorded, not resolved — PSI remains inert
972+
here and must not be counted as protection.
941973

942974
### D30 · If you read it, you carry it — *ayni*, as an invariant
943975

0 commit comments

Comments
 (0)