Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- core/archipelago/src/federation/storage.rs
|
||||
autonomous: true
|
||||
requirements: [FED-01]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A federation removal issued while an auto-sync pass is in flight leaves the peer removed — a sync's pre-removal node-list snapshot can no longer re-save the removed peer (FED-01 adjacency edge)"
|
||||
- "Two concurrent federation node writes both persist — neither silently loses the other's update (the lost-update class behind the reported 'removed nodes reappear' symptom)"
|
||||
- "Removing the last remaining federated node succeeds, leaves an empty node list, and returns Ok with an empty Vec (FED-01 empty edge)"
|
||||
- "A removal whose tombstone write fails returns Err to the caller instead of reporting success (FED-01 failure-surfacing)"
|
||||
- "The tombstone is durably written before the filtered node list is saved, so an interruption between the two never resurrects the removed peer (FED-01 ordering edge)"
|
||||
- "A partially-written federation node list can never be observed by a concurrent reader — the list is written to a sibling temp file and renamed into place"
|
||||
prohibitions:
|
||||
- statement: "Removing a federation node MUST NOT delete or destroy that peer's local data — no app data directory under /var/lib/archipelago, no mesh message history, no credential store is erased by unfederating; removal revokes trust, it never destroys operator data"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: core/archipelago/src/federation/storage.rs
|
||||
provides: "Serialized, crash-safe federation node store"
|
||||
contains: "FEDERATION_STORE_LOCK"
|
||||
key_links:
|
||||
- from: core/archipelago/src/federation/storage.rs
|
||||
to: core/archipelago/src/federation/sync.rs
|
||||
via: "update_node_state acquires FEDERATION_STORE_LOCK for its whole load-mutate-save cycle, so a sync pass cannot interleave with remove_node"
|
||||
pattern: "FEDERATION_STORE_LOCK"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Close the concurrency race that lets a removed federation node come back: serialize every
|
||||
read-modify-write against `federation/nodes.json` behind one async lock, and make the node-list
|
||||
write atomic.
|
||||
|
||||
Purpose: FED-01 — "removing a federation node sticks" is the reason this phase exists. RESEARCH.md
|
||||
identifies an unlocked read-modify-write on `federation/nodes.json` as the primary suspect: the 90s
|
||||
auto-sync loop, the 1800s auto-sync loop, `federation.sync-state`, and `federation.remove-node` all
|
||||
load → mutate → save the same file with zero coordination, so a sync task holding a pre-removal
|
||||
snapshot silently re-saves the peer the operator just removed — with no error logged anywhere.
|
||||
Output: `federation/storage.rs` with a module-level `FEDERATION_STORE_LOCK`, inner/outer function
|
||||
split to avoid re-entrancy deadlock, an atomic temp-file+rename node-list write, and three new
|
||||
regression tests that fail without the lock.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md
|
||||
@core/archipelago/src/federation/storage.rs
|
||||
@core/archipelago/src/update.rs
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `FEDERATION_STORE_LOCK` | `static tokio::sync::Mutex<()>` | `core/archipelago/src/federation/storage.rs` |
|
||||
| `save_nodes_inner` | private async fn (no lock; atomic temp+rename write) | same |
|
||||
| `load_nodes_inner` | private async fn (no lock) | same |
|
||||
| `tombstone_did_inner` / `untombstone_did_inner` | private async fns (no lock) | same |
|
||||
| `test_concurrent_writes_do_not_lose_updates` | `#[tokio::test]` | same (`mod tests`) |
|
||||
| `test_remove_survives_concurrent_state_sync` | `#[tokio::test]` | same (`mod tests`) |
|
||||
| `test_remove_last_node_leaves_empty_list` | `#[tokio::test]` | same (`mod tests`) |
|
||||
| `test_remove_errors_when_tombstone_write_fails` | `#[tokio::test]` | same (`mod tests`) |
|
||||
|
||||
Public function signatures of `load_nodes`, `save_nodes`, `add_node`, `remove_node`,
|
||||
`set_trust_level`, `update_node`, `update_node_state`, `record_peer_transport`, `tombstone_did`,
|
||||
`untombstone_did`, `load_removed_dids` are **unchanged** — callers in `sync.rs`, `handlers.rs`,
|
||||
`server.rs`, and `mesh/mod.rs` compile untouched.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a removal survives an in-flight sync (lock + atomic write)</name>
|
||||
<reversibility rating="reversible">A module-private lock and a temp+rename write are internal
|
||||
to storage.rs behind unchanged public signatures; reverting is a single-file change with no
|
||||
on-disk format change.</reversibility>
|
||||
<files>core/archipelago/src/federation/storage.rs</files>
|
||||
<read_first>
|
||||
- `core/archipelago/src/federation/storage.rs` — the whole file (532 lines). Note in particular:
|
||||
`load_nodes` (L51), `record_peer_transport` (L120), `save_nodes` (L149), `add_node` (L161,
|
||||
calls `untombstone_did`), `remove_node` (L180, calls `tombstone_did`), `tombstone_did` (L214),
|
||||
`untombstone_did` (L237), `set_trust_level` (L256), `update_node` (L272), `update_node_state`
|
||||
(L292), and the existing `#[cfg(test)] mod tests` (L341) with its `make_node(did, onion)`
|
||||
helper and `tempfile::tempdir()` convention.
|
||||
- `core/archipelago/src/update.rs` lines 25-40 — `UPDATE_OP_LOCK`, the in-repo precedent for
|
||||
"two async call sites race on one on-disk resource". Copy its doc-comment style (name the
|
||||
concrete historical incident, then state the acquisition policy).
|
||||
- `.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md` — section "Async static lock
|
||||
for a racy on-disk resource" and "core/archipelago/src/federation/storage.rs (locking fix)".
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `test_concurrent_writes_do_not_lose_updates`: under `#[tokio::test(flavor = "multi_thread", worker_threads = 4)]`,
|
||||
seed one node, then `tokio::join!` an `add_node(B)` with a `set_trust_level(A, Observer)`;
|
||||
afterwards `load_nodes` returns 2 nodes AND node A's trust level is Observer. Without the
|
||||
lock one of the two writes is lost.
|
||||
- `test_remove_survives_concurrent_state_sync`: under the multi-thread flavor, loop 50 times:
|
||||
fresh tempdir, seed nodes A and B, `tokio::join!(remove_node(A), update_node_state(A, snapshot))`,
|
||||
then assert `load_nodes` contains no entry whose `did` is A and `load_removed_dids` contains A.
|
||||
Without the lock this reliably fails within 50 iterations.
|
||||
- `test_remove_last_node_leaves_empty_list`: seed exactly one node, remove it, assert the
|
||||
returned Vec is empty, `load_nodes` returns an empty Vec (not an error), and the DID is
|
||||
tombstoned.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the three tests FIRST in the existing `mod tests` block and confirm they fail (run the
|
||||
verify command and capture the failure) before writing the fix.
|
||||
|
||||
Then add at module scope, immediately after the existing `use` block:
|
||||
`static FEDERATION_STORE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());`
|
||||
with a doc comment that names the failure it prevents — a `federation.remove-node` RPC racing
|
||||
the 90s auto-sync pass, whose pre-removal `load_nodes` snapshot re-saves the removed peer with
|
||||
no error logged — and states the acquisition policy: acquire with `.lock().await`, never
|
||||
`try_lock`. Reject-on-contention is wrong here: a rejected federation write reproduces the very
|
||||
lost-write symptom the lock exists to stop, unlike `update.rs` where rejecting a second
|
||||
concurrent download is the desired UX.
|
||||
|
||||
Restructure so no public function can deadlock on itself. For each function that both takes the
|
||||
lock and calls another locking function, extract the body into a private `*_inner` fn that does
|
||||
NOT acquire, and make the public fn a thin wrapper: acquire the guard, call the inner(s), drop.
|
||||
Required inner fns for this task: `load_nodes_inner`, `save_nodes_inner`, `tombstone_did_inner`,
|
||||
`untombstone_did_inner`. `remove_node` (which calls `tombstone_did`) and `add_node` (which calls
|
||||
`untombstone_did`) must call the `*_inner` variants under a single held guard so tombstone +
|
||||
node-list save are one critical section.
|
||||
|
||||
Convert the node-list write in `save_nodes_inner` to atomic replace: serialize to a sibling path
|
||||
formed by appending a `.tmp` suffix to the resolved nodes file path in the same directory, write
|
||||
it with `tokio::fs::write`, then `tokio::fs::rename` it onto the real path. Keep the existing
|
||||
`.context(...)` error strings so callers' messages are unchanged. Same-directory rename is
|
||||
required — a cross-filesystem rename is not atomic.
|
||||
|
||||
In THIS task route `load_nodes`, `save_nodes`, `remove_node`, `tombstone_did`,
|
||||
`untombstone_did`, and `update_node_state` through the lock. The remaining mutators are Task 2.
|
||||
|
||||
Preserve `remove_node`'s existing ordering exactly: the retain/`bail!`-on-not-found check, then
|
||||
the tombstone write with its failure propagated via `.context("persist removal tombstone")?`,
|
||||
then the node-list save. Do not weaken that ordering or its error propagation.
|
||||
|
||||
Build gotcha from CLAUDE.md: if the build hits `rust-lld: undefined hidden symbol`, that is
|
||||
incremental-cache corruption — re-run with `CARGO_INCREMENTAL=0`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago federation::storage</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && cargo test -p archipelago federation::storage` exits 0.
|
||||
- `grep -c 'FEDERATION_STORE_LOCK' core/archipelago/src/federation/storage.rs` is at least 7.
|
||||
- `grep -c 'tokio::sync::Mutex::const_new' core/archipelago/src/federation/storage.rs` equals 1.
|
||||
- `grep -c 'fs::rename' core/archipelago/src/federation/storage.rs` is at least 1.
|
||||
- `grep -Eq 'async fn test_remove_survives_concurrent_state_sync' core/archipelago/src/federation/storage.rs` succeeds.
|
||||
- `grep -Eq 'async fn test_concurrent_writes_do_not_lose_updates' core/archipelago/src/federation/storage.rs` succeeds.
|
||||
- `grep -Eq 'async fn test_remove_last_node_leaves_empty_list' core/archipelago/src/federation/storage.rs` succeeds.
|
||||
- `grep -Eq 'flavor = "multi_thread"' core/archipelago/src/federation/storage.rs` succeeds (the
|
||||
race tests are useless on the single-threaded default runtime).
|
||||
- The SUMMARY records the captured pre-fix failure output for at least one of the three tests.
|
||||
</acceptance_criteria>
|
||||
<done>The removal-vs-sync race is closed at the storage layer and proven by a test that fails without the lock; the node list is written atomically.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Bring every remaining federation mutator under the lock + surface tombstone-write failure</name>
|
||||
<files>core/archipelago/src/federation/storage.rs</files>
|
||||
<read_first>
|
||||
- `core/archipelago/src/federation/storage.rs` as left by Task 1 — specifically the four
|
||||
mutators not yet routed through the lock: `record_peer_transport` (L120 pre-change),
|
||||
`add_node`, `set_trust_level`, `update_node`.
|
||||
- The existing test `test_remove_nonexistent_node_errors` (L445 pre-change) — mirror its
|
||||
assertion style for the new failure test.
|
||||
</read_first>
|
||||
<action>
|
||||
Route `add_node`, `set_trust_level`, `update_node`, and `record_peer_transport` through
|
||||
`FEDERATION_STORE_LOCK` using the same wrapper + `*_inner` split established in Task 1. Every
|
||||
public function in this module that performs a load → mutate → save cycle must hold the guard
|
||||
for the whole cycle; none may call another lock-acquiring public function while holding it.
|
||||
|
||||
Add `test_remove_errors_when_tombstone_write_fails`: seed a node, then make the tombstone write
|
||||
fail by pre-creating the removed-nodes path as a directory (a directory cannot be replaced by a
|
||||
file write), call `remove_node`, and assert the result is `Err` AND that `load_nodes` still
|
||||
contains the node — a removal whose tombstone never landed must not have half-applied. This is
|
||||
the FED-01 "a failed removal surfaces an error instead of silently no-opping" criterion at the
|
||||
storage layer.
|
||||
|
||||
Do not change any public signature and do not touch `load_invites`/`save_invites` (a separate
|
||||
file with no cross-writer).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago federation</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && cargo test -p archipelago federation` exits 0.
|
||||
- `grep -Eq 'async fn test_remove_errors_when_tombstone_write_fails' core/archipelago/src/federation/storage.rs` succeeds.
|
||||
- `grep -c 'FEDERATION_STORE_LOCK.lock().await' core/archipelago/src/federation/storage.rs` is at least 9.
|
||||
- `cd core && cargo build -p archipelago` exits 0 with no new warnings in `federation::storage`
|
||||
(dead-code warnings on unused `*_inner` fns mean a mutator was missed).
|
||||
- `cd core && cargo test -p archipelago` exits 0 — no caller in `sync.rs`, `handlers.rs`,
|
||||
`server.rs`, or `mesh/mod.rs` was broken by the refactor.
|
||||
</acceptance_criteria>
|
||||
<done>Every federation node-store mutator is serialized; a tombstone-write failure is proven to surface as an error with no half-applied removal.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| federated peer → `federation::sync` → node store | A remote peer's state snapshot crosses into local persisted trust state |
|
||||
| operator RPC (`federation.remove-node`) → node store | An authenticated local operator action mutates trust membership |
|
||||
| process → `federation/nodes.json` on disk | Multiple concurrent async tasks write one file; a crash can leave it partial |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-01 | Tampering | `federation::storage` concurrent read-modify-write | high | mitigate | `FEDERATION_STORE_LOCK` held across the whole load-mutate-save cycle in every mutator (Tasks 1-2); regression test proves a removal survives a concurrent sync |
|
||||
| T-01-02 | Elevation of Privilege | a removed (untrusted) peer regaining federation membership via the race | high | mitigate | Same lock; plus the pre-existing tombstone check in `merge_transitive_peers` and `handle_federation_peer_joined` is left intact and re-verified by `cargo test -p archipelago federation` |
|
||||
| T-01-03 | Denial of Service | a partial `nodes.json` write on crash making the node list unreadable | medium | mitigate | Atomic temp-file + same-directory `fs::rename` in `save_nodes_inner` (Task 1) |
|
||||
| T-01-04 | Denial of Service | lock contention stalling the federation RPC surface | low | accept | Federation writes are infrequent (90s loop + operator actions); `.lock().await` queues rather than rejects, and every critical section is a bounded file read+write |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd core && cargo test -p archipelago federation` — green.
|
||||
- `cd core && cargo test -p archipelago` — green (no caller regressions).
|
||||
- The pre-fix failure of `test_remove_survives_concurrent_state_sync` is recorded in the SUMMARY as
|
||||
evidence the test is fail-first and not vacuous.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every read-modify-write in `federation/storage.rs` is serialized behind one module-level async mutex with no re-entrancy path.
|
||||
- The node list is written atomically (temp file + same-directory rename).
|
||||
- Four new tests exist and pass; at least one is demonstrated to fail without the lock.
|
||||
- Public signatures unchanged; the full `archipelago` test suite is green.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md` when done.
|
||||
Commit with `git add` by explicit path (another agent shares this tree — never `git add -A`), then
|
||||
push per CLAUDE.md: `git push gitea-ai main`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user