Skip to content

Overnight/test harness 2026 04 20#3

Merged
claudenstein merged 115 commits into
mainfrom
overnight/test-harness-2026-04-20
Apr 25, 2026
Merged

Overnight/test harness 2026 04 20#3
claudenstein merged 115 commits into
mainfrom
overnight/test-harness-2026-04-20

Conversation

@claudenstein

Copy link
Copy Markdown
Owner

No description provided.

Frank Dosk and others added 30 commits April 24, 2026 15:21
Implements SPEC §1.3-1.6: the page-level format every Aggregate
companion-index torrent uses for its single-file payload.

Added:
- internal/companion/btree.go (~560 lines):
  * BTreeMagic "SNAGG\0" + 16-byte PageHeader.
  * Record type + bencoded wire form (pk, kw, ih, t, pow, sig)
    with ed25519 verification over canonical sig message.
  * RecordKey = kw || 0x00 || ih for leaf ordering and interior
    separator selection.
  * Interior/root page encode/decode with varint-length separators
    and LE uint32 child piece indices.
  * Leaf page encode/decode with varint-length record bytes.
  * Trailer page carrying publisher pubkey, seq, commit
    fingerprint, per-page counts, and ed25519 publisher sig over
    the preceding fields.
  * ErrPageOverflow signal for the build path to split pages.

- internal/companion/btree_test.go (~270 lines): round-trip
  coverage for records, leaves, interior pages, and trailer;
  tamper detection for record and trailer signatures; magic and
  version validation; RecordKey ordering invariants.

Design notes:
- Payload bytes are zero-padded to pageSize so every page
  occupies exactly one BitTorrent piece. Piece hashes in the
  .torrent authenticate each page; no per-page checksum needed.
- Trailer's field encoding is shared between the on-disk page
  payload and the signing message, so writer/verifier cannot
  disagree on format.
- Record MaxRecordBytes = 256 and MaxKeywordBytes = 64 bound the
  worst-case leaf density; publishers reject oversize at build
  time.
- BTreeVersion 0x01; readers MUST refuse other versions.
- MinPoWBitsDefault = 20 as spec'd; hashcash verification lands
  in P5.1.

Also marks PROPOSAL.md as APPROVED and adds ROADMAP.md tracking
the 11-phase implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two independent phases shipped together since P2.1 has no deps
and P1.2 consumes P1.1's page primitives.

### P2.1 — dhtindex/ppmi.go

PROPOSAL.md §2.1 publisher-pointer mutable item:

- PPMISalt = SHA256("snet.index") — fixed 32-byte double-hashed
  salt used for every publisher. A passive DHT observer cannot
  learn which keywords a publisher covers; the per-publisher
  discrimination happens at the pubkey, not the salt.
- PPMIValue bencoded schema { ih, commit?, topics?, ts, next_pk? }
  with strict size validation (ih=20, commit=0|32, topics=0|32,
  next_pk=0|32) on both encode and decode.
- MaxPPMIValueBytes = 1000 (BEP-44 cap) enforced; typical values
  land at ~50-100 bytes with all fields populated.
- NextPk reserved for v1.1 key rotation, mirroring KeywordValue.

10 tests: round-trip, minimal encoding, salt known-answer,
bad-field-size rejection, garbage rejection, empty rejection,
size estimation, side-by-side coexistence with legacy
KeywordValue.

### P1.2 — companion/build_btree.go

SPEC.md §1.8 deterministic Aggregate index-torrent builder:

- BuildBTree(Input) -> Output: full file bytes ready for
  metainfo.Info.BuildFromFilePath.
- Input records defensive-copied then sorted by RecordKey before
  layout (callers' slice order preserved).
- packLeaves: greedy-pack sorted records into leaf pages until
  the next record would overflow piece budget.
- packInteriorLevel: same greedy rule over (separator, child)
  tuples, producing each interior level.
- Loop upward until the level collapses to one page → root.
- Tiny-tree case: single-leaf trees wrap with a trivial one-child
  root so every output has the same shape root→…→leaves→trailer
  (SPEC §1.3 keeps root kind distinct from leaf kind).
- BFS top-down piece assignment: root at piece 0, then
  contiguous slabs per level, leaves last before trailer.
- Tree fingerprint = SHA256(canonical record stream) computed
  independently of page layout — two publishers with identical
  records produce identical fingerprints regardless of their
  pieceSize choice.
- Trailer signed with publisher's ed25519 privkey; the signing
  message is derived from the same encoder the on-page payload
  uses, so writer and verifier can't disagree on format.

8 tests: tiny tree, determinism (identical bytes across
repeated builds), sort-invariance (shuffling input → same
output), multi-leaf decode-walk (every page valid, every child
index in range), empty rejection, bad pieceSize rejection,
fingerprint matches what a reader would derive from the canonical
stream alone.

Binaries rebuilt: dist/swartznet, dist/swartznet-gui-dev-linux-amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SPEC.md §1.7 subscriber-side implementation. OpenBTree verifies
the trailer signature before returning; Find walks from root
DFS-pruning against the prefix range; VerifyFingerprint is the
optional "full integrity" check for cautious subscribers.

Added:
- internal/companion/read_btree.go:
  * PageSource interface + BytesPageSource for tests & for
    subscribers that choose to fully buffer the companion file.
  * OpenBTree(src) validates the trailer: sig checks against
    embedded pubkey, NumPages matches source size, RootPieceIndex
    = 0. Any mismatch fails open before trusting any payload.
  * Find(prefix) DFS-walks interior pages, pruning children
    whose [lower, upper) range can't overlap [prefix, nextPrefix).
    Per-record ed25519 sig is re-verified on every returned hit;
    PoW is enforced iff trailer.MinPoWBits > 0.
  * VerifyFingerprint re-derives SHA-256(canonical record stream)
    from the leaves and matches the trailer's TreeFingerprint —
    catches any post-publish leaf tampering.
  * rangeOverlapsPrefix helper: nil lower = -∞, nil upper = +∞,
    and nextPrefix handles byte-carry overflow producing nil.
  * leadingZeroBits for PoW threshold; VerifyRecordPoW shipped
    here so P5.1 can share instead of duplicating.

- internal/companion/read_btree_test.go: 16 tests including
  trailer verification and rejection, exact-keyword and prefix
  finds, no-match and empty-prefix corner cases, deep-tree
  pruning over 1000 records spread across 100 keywords,
  fingerprint round-trip, tampered-leaf detection, nextPrefix
  and leadingZeroBits property tables, PoW-enforcement on/off
  paths.

Also adjusted build_btree.go so MinPoWBits == 0 means "not
enforcing" rather than auto-defaulting to 20. Production callers
will pass MinPoWBitsDefault (20) explicitly in P5.1; removing the
auto-default lets unit tests build trees without having to mine
nonces. No behavior change for any existing caller (there are
none outside tests today).

Binaries rebuilt: dist/swartznet, dist/swartznet-gui-dev-linux-amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SPEC.md §2.4-2.7 RIBLT implementation, no external dependency.
Deliberately minimal so the license audit stays clean and the
algorithm stays auditable.

Added:
- internal/swarmsearch/riblt.go:
  * RIBLTElement [32]byte with FNV-1a-derived Key() (nonlinear —
    critical for peeling correctness, see note below).
  * RIBLTSymbol {Count int32, KeyXOR uint64, DataXOR [32]byte}
    matching the wire format in SPEC §2.5.
  * RIBLTEncoder.NextSymbol() produces the next coded symbol
    deterministically; both sides compute identical symbols for
    identical input sets.
  * RIBLTDecoder with auto-peeling on every AddRemoteSymbol.
    Tracks decoded elements and synthetic added/removed lists so
    future local-symbol computations stay consistent — without
    this, every post-decode symbol re-reports the same element.
  * contributes() uses a 12-step geometric rate cycle (mod =
    2^(1+idx%12)) instead of constant 1/3. A constant rate puts
    ~d/3 contributors in every symbol, so d ≥ 5 produces zero
    pure symbols and peeling never starts. The graduated cycle
    always has some positions with expected degree ≈ 1, which
    kickstarts peeling for any plausible d.

- internal/swarmsearch/riblt_test.go: 11 tests including
  Diff0/Diff1/Diff8/Diff100/Diff500 convergence (O(3d) symbols
  across the range, confirmed by logged counts), deterministic
  encoding between two encoders with identical input, pure-function
  stability of contributes(), observed cycle-average rate ≈ 0.083.

Two bugs caught during development:

1. Initial Key() used first 8 bytes as uint64 — *linear* in
   element bytes, so XOR-sums of elements trivially satisfy the
   pure-symbol consistency check and the decoder hallucinated
   non-existent elements into dec.decoded. FNV-1a breaks the
   linearity. (TestConverge_Diff_Symmetric caught this.)

2. Initial Converged() returned true whenever residual diffSymbols
   were all zero — but after decoding a sender-only element, every
   FUTURE symbol where it contributes would carry its bytes
   un-subtracted (we only updated past symbols). Tracking
   syntheticAdded / syntheticRemoved lets effectiveLocalSymbol
   compute the correct reference for each new incoming symbol.

Binaries rebuilt: dist/swartznet, dist/swartznet-gui-dev-linux-amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### P5.1 Sybil-resistance bundle

SPEC.md §1.5 calls for each Record to carry a hashcash nonce
such that SHA256(RecordSigMessage) has at least D leading zero
bits. Verify already landed in P1.3; this commit adds the miner
and wires misbehavior score constants for the sync-ingest path
that P3.2 will build.

Added:
- internal/companion/pow.go:
  * MineRecordPoW(r, bits, maxIter): brute-force search for a
    nonce satisfying the bit threshold. Mean cost 2^bits SHA-256
    ops — ~20 ms at D=20, ~0.3 μs per op on commodity CPUs.
  * SignAndMineRecord(priv, pub, kw, ih, t, bits): convenience
    that orders mine-then-sign correctly (signing before mining
    would invalidate the signature for any nonce other than the
    one signed over).
  * Refuses bits > 40 so an attacker who controls publisher
    settings can't force an honest client into intractable work.
  * ErrPoWExhausted sentinel for bounded-budget callers.

- internal/companion/pow_test.go (8 tests):
  * Round-trip mine + verify at D=8.
  * Verify rejects tampered records (PoW AND signature break).
  * Zero-bits is a no-op, high-bits is refused, exhaustion is
    reported deterministically.
  * recordPreimage matches RecordSigMessage (single source of
    truth for the fields a miner/verifier hash over).
  * SignAndMineRecord composes correctly and round-trips both
    verification paths.
  * Mining is deterministic for identical input (lowest valid
    nonce always wins).

### Misbehavior score additions

SPEC.md §2.7 requires the receiver to drop records whose
signature or PoW fails, and SPEC implies misbehavior accounting
via the existing Bitcoin-Core-style tracker. Added two new
score constants:

- ScoreBadRecordSig = 20 (Severe — strong signal of malicious
  relay injecting records the peer couldn't produce).
- ScoreInsufficientPoW = 10 (Medium — likely old build or
  deliberate, but drips slowly into the ban threshold).

These are ready for P3.2 to call; no ingest path touches them
yet in this commit.

### What's still pending for full P5.1

- Wiring the above score hooks into sync_records ingestion
  (SPEC §2.7). Requires P3.2's sync-session handler first.
- Double-hashed salt — already shipped in P2.1 as
  PPMISalt = SHA256("snet.index").

Binaries rebuilt: dist/swartznet, dist/swartznet-gui-dev-linux-amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the P2.1 PPMI schema into the DHT layer. Production
AnacrolixPutter/Getter gain PutPPMI/GetPPMI methods that map
directly onto BEP-44 mutable-item put/get under the fixed
PPMISalt; MemoryPutterGetter gains the same for in-process tests.

Added:
- internal/dhtindex/ppmi_dht.go:
  * PPMIPutter / PPMIGetter interfaces so callers can mock the
    DHT without needing a real anacrolix/dht server.
  * AnacrolixPutter.PutPPMI: encode, re-decode to interface{},
    BEP-44 put with seq coordination through getput.Put. Auto-
    fills Ts when the caller leaves it zero.
  * AnacrolixGetter.GetPPMI: BEP-44 get traversal, verify via the
    getput layer, decode PPMI value.
  * MemoryPutterGetter.PutPPMI / GetPPMI backed by a separate map
    keyed on SHA1(pub || PPMISalt). Coexists with the legacy
    KeywordValue store — neither clobbers the other because
    targets are distinct.
  * ppmiStoredItem record type.

- internal/dhtindex/ppmi_dht_test.go (7 tests):
  * Round-trip through MemoryPutterGetter preserves every field.
  * Legacy KeywordValue and PPMI coexist; Items() snapshot
    correctly covers only the legacy store.
  * Missing-pubkey Get returns a clear error.
  * Ts auto-fill behavior (documented divergence between memory
    and production paths — memory stores the struct verbatim,
    production auto-fills before encode).
  * Bad-field validation rejects wrong-length IH.
  * Per-pubkey isolation: PPMI under pub A doesn't shadow pub B.
  * Compile-time interface satisfaction check for AnacrolixPutter,
    AnacrolixGetter, and MemoryPutterGetter.

Small change to dht.go: MemoryPutterGetter gains a ppmiStore
map; NewMemoryPutterGetter initialises both maps. Existing
Put/Get paths unchanged.

Binaries rebuilt: dist/swartznet, dist/swartznet-gui-dev-linux-amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the existing Lookup to optionally do PPMI resolution
before the legacy per-keyword fan-out. Publishers who have a
PPMI are recorded in the new LookupResponse.PPMIsResolved field
and SKIPPED on the legacy path (we trust the Aggregate pointer
and don't double-query their per-keyword items). Publishers
without a PPMI fall through to the existing path unchanged.

When no PPMIGetter is attached, Query behaves exactly as before.
The migration per PROPOSAL §6 Phase 1 is dual-write on the
publisher side; this commit adds the matching dual-read.

Added:
- LookupResponse:
  * PPMIsResolved []ResolvedPPMI — successful publisher pointers.
  * PPMIMissing int — indexers whose PPMI fetch failed. Useful
    for "most of your indexers haven't migrated" UI.
- ResolvedPPMI struct with PubKey, Label, and the fetched PPMIValue.
- Lookup.SetPPMIGetter / PPMIGetter accessor methods.
- resolvePPMIs helper that fans out GetPPMI in parallel.
- legacyQuery helper factored out of Query so the Aggregate
  path and the legacy path can share encoding / merge / score /
  source-tracking without duplication.

Refactored:
- Lookup.Query now orchestrates the two paths: fan out PPMIs
  first (when getter is set), exclude resolved publishers from
  the legacy fallback set, run the legacy path on the
  fallback-only subset. IndexersAsked reports the total (PPMI
  + legacy).

- internal/dhtindex/lookup_ppmi_test.go (5 tests):
  * Without a PPMI getter, Query is identical to pre-P2.3.
  * Mixed migration state: A has PPMI, B is legacy — A
    resolves via Aggregate path, B returns a legacy hit, total
    IndexersAsked = 2.
  * All publishers migrated → PPMIsResolved populated, Hits
    empty (no legacy fallback).
  * No publishers migrated → PPMIMissing equals total, all
    legacy fall-through.
  * SetPPMIGetter(nil) cleanly disables the Aggregate path.

All 70+ existing dhtindex tests continue to pass unchanged.
Binaries rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SPEC.md §2 msg_types 4-8 implementation. All five wire types
decode/encode with size validation; a SyncSession state machine
holds one end of an RIBLT exchange and produces/consumes frames
on demand. Callers wire frames through LTEP themselves — keeping
the session I/O-free makes it trivially unit-testable and
reusable for future transports (if we ever add, say, a localhost
HTTP debug endpoint for it).

Added:
- internal/swarmsearch/sync_wire.go (~220 lines):
  * MsgTypeSyncBegin (4), SyncSymbols (5), SyncNeed (6),
    SyncRecords (7), SyncEnd (8) constants.
  * Status / budget constants: SyncStatusConverged /
    LimitExceeded / Aborted; DefaultSyncMaxSymbols (2000),
    DefaultSyncMaxBytes (1 MiB), MaxSymbolsPerMessage (100),
    MaxRecordsPerMessage (500), MaxNeedIDsPerMessage (1000).
  * SyncFilter {Pubkeys, Since, Prefix}.
  * Five message structs matching SPEC §2.3-2.8 byte schemas.
  * Encode / Decode pair per type with per-field size validation
    (element 32B, sig 64B, ih 20B, pubkey 32B, id 32B).
  * Decoders reject payloads carrying the wrong msg_type
    discriminator, mirroring existing v1 message decoders.

- internal/swarmsearch/sync_session.go (~340 lines):
  * SyncRole (initiator / responder), SyncSessionPhase machine
    with six states (Idle → Begun → SymbolsFlowing → Needed →
    Fulfilled → Ended).
  * LocalRecord: wire-friendly view of a signed Aggregate record.
    Deliberately duplicated from companion.Record so the session
    can stay import-cycle-free (the signature + PoW re-
    verification happens at the outer handler layer).
  * NewSyncSession indexes records by RIBLT element ID (SHA-256
    over pk || kw || ih || t_LE per SPEC §2.4), bootstrapping
    both the encoder and decoder's local set.
  * Begin / ApplyBegin negotiate the session.
  * ProduceSymbols emits up to MaxSymbolsPerMessage-capped
    batches, honoring the maxSymbols budget with
    ErrSymbolBudgetExceeded.
  * ApplySymbols ingests peer batches, running auto-peeling.
  * NeedIDs / RemovedIDs expose decoded differences.
  * NeedFrame / ApplyNeed handle the pull request.
  * BuildRecordsFrame / ApplyRecords handle the bulk delivery.
  * Finish / ApplyEnd close the session with observed counts.
  * RecordByID is the responder-side lookup hook.

- internal/swarmsearch/sync_wire_test.go (10 tests): round-trip
  every message type, reject empty / oversized symbol batches,
  reject bad DataXOR / pk / sig / id sizes, reject mismatched
  msg_type discriminators.

- internal/swarmsearch/sync_session_test.go (10 tests):
  * End-to-end roundtrip: sender {a,b,c,d} + receiver {a,b,e} →
    decodes Added=2 (c,d), Removed=1 (e), delivers records.
  * One-sided 40-record diff converges in ≤300 symbols.
  * Phase guards reject calls from wrong phase.
  * TxID mismatch surfaces as protocol error.
  * Budget enforcement clamps produced symbols and raises
    ErrSymbolBudgetExceeded on subsequent calls.
  * Empty NeedFrame ("I'm done") advances to Needed phase.
  * Finish / ApplyEnd both land in Ended phase.
  * RecordByID round-trips against stored ID.
  * localRecordID is deterministic for identical input.

Not in this commit: LTEP dispatch integration in handler.go.
The session is fully driven by test-controlled frame flow today;
plumbing it through the connection-level handler — with rate-
limit enforcement and misbehavior scoring from P5.1 on bad
sigs/PoW — lands in the Final integration phase alongside the
wire-compat matrix regression gates.

Binaries rebuilt: dist/swartznet, dist/swartznet-gui-dev-linux-amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SPEC.md §3 orchestration for a fresh subscriber. Three
independent channels populate the Lookup indexer set:

- Channel A (RunAnchors): fetch each anchor pubkey's PPMI and
  admit with AnchorReputation seed on success.
- Channel B (CandidateFromCrawl): admit a pubkey harvested via
  BEP-51 + snet.pubkey metainfo inspection, subject to sig
  validation and Bloom-policy gate.
- Channel C (IngestEndorsement): accumulate distinct endorsers
  for each candidate; admit on EndorsementThreshold reach OR
  Bloom-policy gate.

Admission bookkeeping:
- admitted set + MaxTrackedPublishers cap (default 100)
  so a spammy gossip flood can't blow out the indexer set.
- AnchorReputation (0.8) and CandidateReputation (0.1)
  seed distinct trust levels; EigenTrust-style anchoring.
- tracker/bloom optional; bloomPolicy currently default-denies
  when no Bloom filter is attached (conservative — channel C
  still admits via pure endorsement threshold).

Added:
- internal/daemon/bootstrap.go (~280 lines):
  * DefaultAnchorPubkeys []string (empty pending production
    release), BootstrapOptions, Bootstrap struct.
  * NewBootstrap constructor with hex-anchor validation and
    defaults.
  * RunAnchors: parallel PPMI fetch across anchor set, returns
    (successCount, perAnchorErrors).
  * IngestEndorsement / CandidateFromCrawl admission paths.
  * IsAdmitted / IsPending / AdmittedCount / AnchorKeys
    inspectors.
  * Internal admit() registers to Lookup + seeds tracker.

- internal/daemon/bootstrap_test.go (10 tests):
  * Anchor hex validation (non-hex, wrong-length, empty-string
    handling).
  * RunAnchors: successful anchors admitted to Lookup, failed
    anchors surfaced in errs slice, anchor labels populated.
  * RunAnchorsNoPPMIGetter fast-errors cleanly.
  * Endorsement threshold: 2 distinct endorsers → admit;
    duplicate endorser doesn't cross threshold; idempotent on
    already-admitted.
  * CrawlCandidate: invalid sig rejected; bloom-default-deny
    without Bloom filter.
  * MaxTrackedPublishersCap refuses admission past the cap.
  * AdmitSetsLookupLabel: admitted pubkeys appear in
    lookup.Indexers() with non-empty labels.

Not in this commit:
- The actual BEP-51 crawler (triggers CandidateFromCrawl) —
  requires a live DHT; the Final phase will wire it through
  the engine.
- HTTPS anchor fallback (P5.2) for when channels A/B/C all
  return nothing.

Binaries rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### P5.2 HTTPS anchor fallback (SPEC.md §3.5)

Last-ditch channel for subscribers whose A/B/C channels all
returned nothing. Fetches a JSON anchor list from a project-
operated endpoint, parses hex pubkeys, and extends the
Bootstrap's anchor set. Serves ONLY pubkeys, never records —
compromising the endpoint can delay but not poison bootstrap.

Added:
- internal/daemon/bootstrap_https.go:
  * DefaultBootstrapURL (empty pending production release).
  * HTTPSFallbackClient interface for test injection + a
    production http.Client adapter with sane timeout.
  * FallbackToHTTPS(ctx, url, client): parse JSON array of
    64-char hex pubkeys, dedupe against existing anchors,
    extend in place. Returns count of newly-added anchors.
  * MaxAnchorBootstrapBytes (64 KiB) ceiling blocks hostile
    megabytes from reaching the parser.

- internal/daemon/bootstrap_https_test.go (8 tests): new
  anchors admitted, duplicates deduped, malformed entries
  silently skipped, empty URL refused, transport errors
  propagated, bad JSON rejected, oversized responses
  rejected, post-fallback anchor list survives RunAnchors.

### Final — end-to-end Aggregate integration

internal/daemon/aggregate_e2e_test.go demonstrates the full
publisher → DHT → subscriber flow:

- TestAggregateEndToEnd:
  * Publisher signs 30 records across {linux, ubuntu, debian}.
  * companion.BuildBTree produces a piece-aligned B-tree with
    trailer signed by the publisher's ed25519 key.
  * Publisher's PPMI stored in the in-memory DHT via
    MemoryPutterGetter.PutPPMI.
  * Bootstrap admits the publisher as an anchor, succeeding
    RunAnchors call adds it to Lookup.
  * Lookup.Query("ubuntu") resolves via the PPMI path: the
    committed fingerprint matches the builder's output.
  * companion.BTreeReader opens the tree via BytesPageSource
    (stand-in for a seeded torrent), verifies trailer sig,
    and Find("ubuntu") returns exactly the ubuntu records.
  * Every returned record's ed25519 sig re-verifies.
  * VerifyFingerprint re-derives SHA-256 over the canonical
    record stream and matches the trailer.

- TestAggregateMixedMigration:
  * Publisher A has PPMI; Publisher B is on legacy per-keyword.
  * Lookup.Query falls through per-publisher: A resolves via
    PPMI, B returns legacy Hits. PPMIMissing = 1 (B).

Small shim added: MemoryPutterGetter.PubKey() accessor so
tests can fetch the public key it signs puts under, without
reaching into unexported fields.

### Status

All 12 roadmap phases complete. ~7500 lines shipped across
companion/, dhtindex/, swarmsearch/, daemon/ with ~120 new
test functions. Cross-package race sweep green. Binaries
rebuilt.

Next steps (post-roadmap, not in this commit):
- LTEP dispatch integration for the sync msg_types 4-8
  (deferred from P3.2).
- BEP-51 crawler wiring to CandidateFromCrawl (deferred from
  P4.1). Requires a live anacrolix/dht server.
- docs/05-integration-design.md §4.3 amendment to reflect the
  PPMI inversion now that it's implemented.
- CHANGELOG.md entry for the v0.5.0 milestone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the LTEP dispatch deferred from P3.2. Now two
sn_search peers can actually exchange sync frames over TCP,
subject to the new capability gate.

### New services bit

- BitSetReconciliation = 1 << 9 in internal/swarmsearch/services.go.
  Peers that don't advertise this bit in their peer_announce
  MUST NOT receive sync frames. Reserved-bit allocation follows
  the existing rule: never reuse, always next-available.

### Handler dispatch

- HandleMessage gains five new cases (msg_types 4-8) routing to
  handleSyncFrame, which:
  * Checks peer's Services mask for BitSetReconciliation.
  * If not set: reply reject code 2, charge ScoreUnexpectedMessage,
    return without state change.
  * If set: decode the specific frame, dispatch to onSync* handler.
- Per-peer session map added to Protocol: map[addr]map[txid]*SyncSession.
- onSyncBegin: creates a responder session and immediately
  Finish()-es with SyncStatusConverged (no records source yet;
  engine integration will plumb a LocalRecord provider).
  Wire behavior is correct — SPEC §2 permits zero-symbol sessions
  to complete immediately.
- onSyncNeed: builds a records frame with every requested ID
  reported as missing (same rationale: no records source yet).
- onSyncSymbols / onSyncRecords / onSyncEnd: route to the
  matching session; ignore if unknown txid.
- registerSyncSession / lookupSyncSession / releaseSyncSession
  helpers manage the per-peer state with fine-grained locking.

### Tests (internal/swarmsearch/sync_handler_test.go — 6 tests)

- TestSyncCapabilityGateRejects: peer without bit 9 → reject
  code 2, no session registered.
- TestSyncCapableBeginReplysWithEnd: peer with bit 9 → sync_end
  reply converged, session released afterward.
- TestSyncSymbolsUnknownSession: unknown txid → silent drop,
  no reply, no panic.
- TestSyncNeedAllMissing: known session + missing IDs → sync_records
  with empty Records and full Missing list.
- TestSyncBadPayloadCharges: malformed sync frame charges
  ScoreBadBencode.
- TestBitSetReconciliationBitOps: Has/With/Without round-trip
  for the new bit.

### Docs

- CHANGELOG.md: new Unreleased "Aggregate redesign" section
  summarising the 10-commit series + remaining engine plumbing.
- docs/06-bep-sn_search-draft.md: capability table gets bit 9
  entry; message-types table gains rows for msg_types 4-8 with
  a pointer to SPEC §2.

Binaries rebuilt. Full swarmsearch race sweep green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously onSyncBegin always shipped a zero-record converged
reply because no hook existed to load matching local records.
This commit introduces a RecordSource interface, makes the
handler query it on sync_begin, and produces a real first batch
of RIBLT symbols when records exist.

### New surface

- RecordSource interface:
    LocalRecords(filter SyncFilter) ([]LocalRecord, error)
  Any provider that can enumerate the node's local signed-record
  set satisfies it.
- Protocol.SetRecordSource(src) / RecordSource() accessors.
- recordSource field added to Protocol under the existing mu.

### Handler behavior change

- onSyncBegin:
  * Queries RecordSource.LocalRecords(m.Filter); a nil source or
    LocalRecords error is treated as "no records" (still correct
    wire-level behavior; quiet telemetry).
  * Constructs the SyncSession with the loaded records so
    subsequent ApplyNeed lookups resolve locally.
  * Zero-record path still emits sync_end converged and releases
    the session.
  * Non-zero path calls ProduceSymbols(MaxSymbolsPerMessage) and
    replies with a SyncSymbols frame. The session stays registered
    for subsequent sync_need / sync_symbols traffic.
- onSyncNeed:
  * Calls sess.ApplyNeed(m) which looks up each requested ID
    against the session's pre-indexed records; known IDs come
    back as LocalRecords, unknown land in missing.
  * Ships BuildRecordsFrame(records, missing) via
    sendSyncRecords.
- sendSyncSymbols helper added (mirror of sendSyncRecords /
  sendSyncEnd).

### Tests (internal/swarmsearch/record_source_test.go — 6 tests)

- TestRecordSourceAccessors: Get/Set round-trip, detach
  with nil.
- TestSyncBeginStreamSymbolsWhenRecordSourcePresent: sync_begin
  with a 5-record source now replies SyncSymbols (not SyncEnd)
  and the session remains registered.
- TestSyncBeginZeroRecordsFromSource: empty source falls through
  to sync_end converged.
- TestSyncBeginRecordSourceError: source.LocalRecords error
  treated as "no records"; handler ships sync_end converged
  without crashing.
- TestSyncNeedReturnsRecordsFromSource: sync_need with known IDs
  receives a SyncRecords reply carrying those records; Missing
  is empty.
- TestSyncBeginAppliesFilter: filter.Prefix on SyncBegin is
  honored — ubuntu records filtered out when prefix="lin";
  subsequent sync_need for an ubuntu ID returns it as Missing.

Binaries rebuilt. Full swarmsearch race sweep green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keeps docs/ in sync with the v0.5.0 Aggregate redesign per
CLAUDE.md's "docs and CHANGELOG track the code" rule.

- docs/05-integration-design.md §4.3 gains:
  * A supersession callout at the top pointing readers at
    PROPOSAL.md and SPEC.md for the new PPMI layout.
  * A new §4.3.1 "Aggregate (v0.5.0) — per-publisher PPMI
    layout" describing the target/salt/v schema, the
    O(publishers) cost collapse, the double-hashed-salt
    privacy property, commit-bound trailer verification, the
    v0.5/v0.6/v0.7 dual-write/dual-read migration staging, the
    three-channel bootstrap, and the BitSetReconciliation
    peer-wire complement.

- docs/07-bep-dht-keyword-index-draft.md:
  * Same supersession notice at the top of the Abstract.
  * Draft text kept verbatim for the dual-read migration window;
    per-keyword items remain a valid legacy format any BEP-44
    DHT node will keep serving.

No code changes. Build still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-process Go benchmarks for the two SPEC-named regression gates.
The testbed/Docker bootstrap scenario is its own integration
track; this commit handles the unit-level ones.

### internal/companion/btree_bench_test.go

- BenchmarkPrefixQuery: narrow prefix query over a 50k-record
  3-level B-tree. Measures the pruned-walk hot path.
  Measured: ~16.8 ms/op on an i9-14900K — well under SPEC's
  <50 ms target. Scaling to 5M records adds at most one more
  interior level, so the O(log n) walk stays under 50 ms.
- BenchmarkPrefixQueryWide: prefix that matches every record.
  Measures the worst-case where every leaf overlaps; dominated
  by per-record ed25519 signature verification. Useful lower
  bound for "broad keyword" workloads.
- BenchmarkBuildBTree: construction cost over 10k records. Catches
  regressions in the sort/pack/sign pipeline.

### internal/swarmsearch/riblt_bench_test.go

- BenchmarkRIBLTConverge_Diff{0,10,100,500}: parameterised
  convergence measurement. Records both symbols/op (the
  algorithmic signal) and bytes/op (48-byte symbol assumption)
  so CI can gate on bandwidth directly.

  Measured on an i9-14900K:
    Diff=0   →    20 symbols /    960 bytes
    Diff=10  →   118 symbols /  5.7 kB
    Diff=100 →   732 symbols /   35 kB
    Diff=500 →  3244 symbols /  156 kB

  SPEC §7 targets "<5 kB/peer/hr steady state" for a 10k-peer
  network producing ~1000 new records/hr. That translates to
  per-meeting diffs around 50 — sitting between our Diff=10 and
  Diff=100 benchmarks, implying ~10-20 kB actual per-meeting
  steady state. Within the same order of magnitude as the SPEC
  aspiration; the <5 kB target was optimistic for the chosen
  rate distribution. Future tuning (denser peer meshes, different
  cycle lengths in contributes()) may close the gap.

- BenchmarkRIBLTEncoderNextSymbol: isolates raw symbol-production
  cost — independent of decode — for catching regressions in
  contributes() or the XOR accumulator.

Benchmarks run only with -bench flag; regular `go test ./...`
is unchanged. No production code touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-facing CLI surface for the v0.5.0 Aggregate redesign. Both
subcommands operate on the single-file payload inside an
Aggregate companion torrent (the bytes companion.BuildBTree
produces) — no daemon required, no HTTP API dependency, no
live DHT. Useful for operators validating their own index build
before publishing and for subscribers inspecting companion
torrents they've downloaded.

### cmd_aggregate.go

  cmdAggregate dispatches to one of:

  - inspect <index-file>
      Opens the B-tree via companion.OpenBTree (which verifies
      the trailer signature), prints file size, page count,
      record count, publisher pubkey, sequence, created ts,
      min PoW bits, and tree fingerprint.

  - find [--piece-size=N] [--verify] <index-file> <prefix>
      Runs a prefix-query via BTreeReader.Find, prints each
      matching record as "<infohash-hex>  <keyword>  t=<ts>".
      --verify also runs VerifyFingerprint (scans every leaf —
      slower, catches any post-publish tampering that escaped
      the trailer-sig check).

  - help
      Prints usage.

Both subcommands take --piece-size so users who published at a
non-default piece size (256 KiB) can still inspect their files.

### cmd_aggregate_test.go (9 tests)

  - inspect prints records, fingerprint, and core fields.
  - inspect rejects missing files with an exit code and a
    read-error message on stderr.
  - inspect rejects malformed (zero-filled) files — the bad-magic
    path in OpenBTree fires correctly.
  - find returns prefix matches, filters out non-matching
    keywords.
  - find returns "0 records" for an empty result, still exit OK.
  - --verify flag drives VerifyFingerprint over a known-good tree.
  - Unknown subcommand returns exitUsage.
  - help returns exitOK and mentions 'aggregate' in output.
  - No-subcommand case prints usage + exits with exitUsage.

Top-level main.go routes "aggregate" to cmdAggregate and lists
it in `swartznet help`.

Full repo race sweep clean (cmd, companion, dhtindex, swarmsearch,
engine, daemon, indexer, gui, httpapi, etc.). Binaries rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v0.5.0 CLI loop: operators can now build, inspect, and
query Aggregate companion-index files end to end without a running
daemon. Pairs with the inspect/find subcommands landed in 3416638.

### Input schema (JSONL, one record per line)

  {"kw": "ubuntu", "ih": "<40-char hex sha-1>", "t": <unix ts>}

The `t` field is optional (defaults to 0). Empty kw or wrong-length
ih are rejected early with a line number so users can debug their
own extraction pipelines without guessing.

### cmd_aggregate_build.go

  Flags:
    --in FILE        JSONL input ("-" for stdin), default "-".
    --out FILE       Output path (required).
    --key FILE       Identity file to sign with; defaults to the
                     node's ~/.local/share/swartznet/identity.key.
    --seq N          Trailer sequence number (monotonic per publisher).
    --piece-size N   Piece size in bytes (must match .torrent metainfo).
    --pow-bits N     Hashcash difficulty (0=off, 20=production, >40 refused).

  Flow: load key → parse JSONL → sign (+ optional mine) each record
  → companion.BuildBTree → write output. Pure offline — no DHT, no
  daemon, no network. Print summary: records, pages, bytes,
  fingerprint, output path.

### cmd_aggregate_build_test.go (7 tests)

  - TestCmdAggregateBuildInspectFindChain: end-to-end — build 3
    records, inspect reports 3, find "ubu" returns both ubuntu
    entries. Validates the full CLI loop in one pass.
  - TestCmdAggregateBuildRequiresOut: missing --out is exitUsage.
  - TestCmdAggregateBuildRefusesHighPoW: --pow-bits=50 refused
    (cost-prohibitive guard from companion.MineRecordPoW).
  - TestCmdAggregateBuildRejectsBadJSONL: "not json at all" surfaces
    with line 1 in stderr.
  - TestCmdAggregateBuildRejectsBadInfohash: short ih caught at
    record-parse, before any signing.
  - TestCmdAggregateBuildRejectsEmptyInput: empty JSONL → exitUsage.
  - TestCmdAggregateBuildWithSmallPoW: --pow-bits=4 completes
    quickly and min_pow_bits=4 is readable in the trailer via
    `aggregate inspect`.

Live smoke tested: build 3 records, inspect reports the trailer
correctly, find "ubu" returns 2 matches with the expected
infohashes and timestamps.

Binaries rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-memory cache keyed on RIBLT element ID, implements the
RecordSource interface introduced in 9e6f613. The engine can now
populate it as the local publisher signs records; sync
responders see fresh records immediately on the next sync_begin
without any poll cadence.

### internal/swarmsearch/record_cache.go

  RecordCache: thread-safe map[ID]LocalRecord with:

  - Add(r):        insert (idempotent — same pk/kw/ih/t → same ID).
  - Remove(id):    delete by RIBLT element ID.
  - RemoveByRecord(r): compute ID then delete.
  - Get(id):       point lookup, returns (record, ok).
  - Len():         size of the cache.
  - Snapshot():    independent copy of every record (mutation-safe).

  LocalRecords(filter SyncFilter) ([]LocalRecord, error):
    implements RecordSource. Filters by:
    * Pubkeys — record.Pk must be in the supplied set; invalid-
      length entries silently ignored.
    * Since   — record.T >= filter.Since.
    * Prefix  — record.Kw starts with filter.Prefix.
    All conditions conjunct; zero-value filter returns every record.

  cacheRecordID is the deterministic 32-byte key derivation —
  matches localRecordID (sync_session.go) bit-for-bit so a record
  added to the cache is findable via the session's ApplyNeed
  without an ID-translation layer.

### internal/swarmsearch/record_cache_test.go (15 tests)

  - TestCacheIDMatchesSessionID: explicit invariant that
    cacheRecordID == localRecordID for the same input. Guards
    against future drift where a refactor might touch one but
    not the other.
  - Basic Add/Get/Remove/Len/RemoveByRecord round-trips.
  - Idempotent Add: re-adding same record leaves Len unchanged.
  - LocalRecords:
    * Empty filter returns every record.
    * Pubkeys filter narrows to listed publishers.
    * Since filter drops records below threshold.
    * Prefix filter drops records whose kw doesn't start with it.
    * Combined filters conjunct correctly — the intersection
      test covers 4 records, 3 conditions, 1 expected hit.
    * Invalid-length pubkeys silently ignored.
  - Compile-time interface satisfaction check (RecordSource).
  - Concurrency: parallel writer + reader goroutines, -race
    clean, final Len == N.
  - Snapshot returns independent slice (mutation doesn't leak).
  - TestRecordCacheDrivesSyncBegin: end-to-end — wire a
    RecordCache as RecordSource on a real Protocol, send
    sync_begin, assert the reply is SyncSymbols (not the zero-
    record SyncEnd path). Proves cache.Add → sync responder.

Scope note: engine wiring (calling cache.Add when the local
publisher mints a record) is the next step and lives in
internal/engine. This commit ships the standalone component so
it can be unit-tested in isolation; the engine-side call sites
land separately.

Binaries rebuilt. Full swarmsearch race sweep green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lightweight JSON endpoint exposing v0.5 Aggregate-track state.
Lets CLI/UI consumers see what's going on in the new layer
without cross-cutting into swarmsearch internals.

### internal/httpapi/aggregate.go

  GET /aggregate response shape:
    {
      "ppmi_enabled":        true|false,
      "known_indexers":       int,
      "indexers":             [{"pk": hex64, "label": "..."}],
      "record_source_kind":   "cache" | "custom" | "",
      "record_cache_size":    int,
      "services":             hex16 (uint64 ServiceBits)
    }

  - PPMIEnabled reflects lookup.PPMIGetter() != nil.
  - Indexers snapshot comes straight from Lookup.Indexers();
    order is not sorted — UI can sort if display matters.
  - RecordSourceKind identifies the source type:
    * "cache"  → *swarmsearch.RecordCache (size reported in
                 RecordCacheSize).
    * "custom" → any other impl (size not reported; we don't
                 leak the underlying type name).
    * ""       → no source attached.
  - ServicesAdvertised is the uint64 bitmask big-endian
    hex-encoded (16 chars). Bit 9 = 0x200 = BitSetReconciliation.

### Server wiring

  server.go registers `GET /aggregate → handleAggregateStatus`
  next to the other read-side endpoints. Nil swarm/lookup collabs
  surface as zero-valued fields (matches the
  "all Options fields optional" pattern).

### internal/httpapi/aggregate_test.go (6 tests)

  - Empty daemon (no swarm, no lookup) → zero-valued response,
    status 200.
  - Lookup populated with one indexer but no PPMI getter:
    Indexers[0] carries the hex pubkey + label;
    PPMIEnabled=false.
  - PPMI getter attached: PPMIEnabled=true.
  - Swarm with a RecordCache source: kind="cache", size matches,
    ServicesAdvertised is 16 hex chars.
  - Swarm with a non-cache RecordSource (anonymous struct):
    kind="custom", size=0.
  - Response Content-Type: application/json.

Binaries rebuilt. Full httpapi race sweep green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hits GET /aggregate alongside /status and appends a block for the
v0.5 Aggregate track. Keeps the command usefully informative in
the dual-read migration window when both legacy and Aggregate
paths may be active.

### cmd_status.go

  - fetchAggregateBlock: best-effort GET /aggregate. A 404 (older
    daemon), network error, or decode failure returns nil; the
    renderer quietly omits the block. This keeps the command
    backward-compatible — users running a daemon from before
    fac200d still see the normal output.

  - emitStatusText gains an `agg *AggregateStatusResponse` arg.
    After the Publisher block, when agg != nil, calls
    emitAggregateBlock which prints:
      PPMI enabled, known indexers, record source kind,
      record cache size, services advertised (hex).
    Lines are conditionally omitted when their values are
    zero/empty so the block stays tight.

  - The "(no keywords published yet)" branch no longer
    return-early-short-circuits the function; both paths fall
    through to the optional Aggregate block.

  - --json output now wraps both payloads in a single JSON
    object: {"status": ..., "aggregate": ...}.

  - Existing cmd_status_test.go call sites updated to pass nil
    for the new agg argument.

### cmd_status_aggregate_test.go (4 tests)

  - TestStatusTextOmitsAggregateWhenNil: nil agg → no Aggregate
    block rendered.
  - TestStatusTextRendersAggregateBlock: populated response
    produces every expected line (PPMI, indexers, record source,
    cache size, services hex).
  - TestStatusTextAggregateBlockWithoutPPMI: PPMIEnabled=false
    still renders the block, just with the flag off; record-source
    line is omitted when kind is empty.
  - TestStatusTextAggregateBlockEmptyCache: custom kind + 0 size
    → kind line renders, size line omitted.

Binaries rebuilt. Full cmd/swartznet race sweep green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the CLI status-tab addition from ec7a791. The embedded
web UI's Status panel now carries an "Aggregate (v0.5)" card next
to the existing Local/Swarm/DHT/Publisher cards, populated from
GET /aggregate. Older daemons that 404 on /aggregate silently
skip the card — pattern copied from the existing Bloom/Reputation
conditional cards.

### Changes

- app.js refreshStatus: adds /aggregate to the Promise.all fetch
  with .catch(() => null) so older daemons don't break the
  whole status refresh.
- renderStatus gains an `agg` parameter; when non-null, appends a
  new card with PPMI enabled (yes/no), known indexers count,
  optional record source kind, optional cache size.
- Conditional row rendering: record-source line omitted when
  kind is empty; cache-size line omitted when size is 0 — matches
  the CLI renderer's conventions from ec7a791.

### internal/httpapi/web/aggregate_card_test.go (3 tests)

Since the embedded JS has no browser-level test harness, tests
assert on the bundle content directly — cheap, deterministic,
catches regressions where a refactor drops the card or its fetch.

- TestAggregateCardPresentInBundle: the 9 critical substrings
  (/aggregate, card title, field reads) all present.
- TestStatusCardsPresentInBundle: the older Local/Swarm/Publisher
  cards still present — guards against accidental removal during
  future refactors.
- TestAggregateFetchTolerantOfMissingEndpoint: the fetch's
  .catch(...) idiom is preserved — otherwise older daemons would
  break the whole status panel.

Binaries rebuilt. httpapi/web race sweep green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the web-UI Aggregate card from 160b5e6 and the CLI status
block from ec7a791 into the Fyne-based native GUI. Shows the four
v0.5 Aggregate-track signals an operator wants to see on-screen:
PPMI enabled, known indexers, record source kind, cache size.

### internal/gui/status.go

- New card widget "Aggregate (v0.5)" with 4 label rows. Slots
  into the adaptive grid next to DHT Routing and DHT Publisher.
- refresh() introspects the live subsystems:
  * Lookup.PPMIGetter() != nil → "yes"/"no".
  * Lookup.Indexers() → count.
  * SwarmSearch().RecordSource() → "cache" (with Len()) /
    "custom" / "-".
- Nil-safe throughout: a daemon without Aggregate wiring (no
  Lookup, no SwarmSearch, no RecordSource) renders all zeros
  and "no" rather than crashing.

Follows the same pattern as the DHT routing-table card landed
in 5ab4d71: plain widget.Card, labelRow helper, fyne.Do for
the UI-thread update from the poll goroutine.

Binaries rebuilt (CLI + GUI). internal/gui race sweep green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last wiring gap in the Aggregate track. Every engine
constructor path now creates an empty *swarmsearch.RecordCache
and attaches it to the swarm protocol via SetRecordSource.
Publishers feed it via Engine.RecordCache().Add(...); sync
responders serve from it automatically; the /aggregate endpoint,
CLI status block, web card, and GUI card all flip from showing
"no record source" to reporting "cache" with a real Len().

### Changes in internal/engine/engine.go

- Engine gains a `recCache *swarmsearch.RecordCache` field,
  always non-nil after engine.New.
- engine.New constructs the cache immediately after the swarm
  protocol and calls swarm.SetRecordSource(recCache). This is
  unconditional — cheap empty map, consistent observable state
  across headless-test and production constructor paths.
- New Engine.RecordCache() accessor next to the other getters.
  Documented as the entry point for publisher hot-paths that
  mint signed records.

### Tests (internal/engine/record_cache_wiring_test.go)

- TestEngineNewAttachesRecordCache: engine.New produces a non-
  nil cache, swarm.RecordSource() returns it, and it is the
  same *RecordCache instance the accessor exposes. Catches the
  class of bug where an engine constructor silently replaces
  the source with a different impl.
- TestEngineRecordCacheAddVisibleToSource: adding a record via
  Engine.RecordCache().Add(r) makes it visible to the swarm's
  attached RecordSource via LocalRecords. Confirms the wiring
  is live, not just wired-at-construction-then-disconnected.

Full repo race sweep clean (15 packages). Binaries rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Running daemons with an identity now push one LocalRecord per
tokenised keyword into the RecordCache whenever a torrent's
metadata arrives — closes the loop from PROPOSAL.md: metadata
→ mint → sync responder serves records over sn_search.

### Changes in internal/engine/engine.go

- autoIndex fires a new Engine.MintAggregateRecords(ih, name)
  call alongside the existing Layer-D publisher submit. The
  minting logic is extracted into an exported method so tests
  can exercise it without spinning up a torrent-add fixture.
- MintAggregateRecords tokenises `name` via dhtindex.TokenizeAll
  (same analyser the legacy publisher uses, so both paths agree
  on keyword shape). For each token: SignAndMineRecord under the
  engine's identity, convert to swarmsearch.LocalRecord, Add to
  the cache.
- PoW is 0 (disabled) in v0.5 dual-read window — no reader below
  a threshold today; bumping to MinPoWBitsDefault is a follow-on
  schema bump.
- Silent-skip when identity or cache is nil — headless test setups
  still exercise every other autoIndex code path without crashing.
- Debug log at end reports token count and resulting cache size
  for regression observability.

### Tests (internal/engine/mint_records_test.go — 4 tests)

- TestMintAggregateRecordsPopulatesCache: real engine-with-
  identity, Mint a torrent named "Ubuntu Linux 24.04 LTS" →
  cache contains records for "ubuntu" AND "linux"; every stored
  record's ed25519 sig re-verifies under the publisher's pubkey.
- TestMintAggregateRecordsSilentWithoutIdentity: engine built
  with IdentityPath="" (explicit opt-out — config.Default() would
  otherwise point at the user's real identity file) does NOT
  populate the cache. Documents the no-identity guard.
- TestMintAggregateRecordsIdempotent: successive mints at
  different timestamps grow the cache rather than shrink;
  documents that "idempotent" here means safe-to-call-repeatedly,
  not Len-stays-put.
- TestMintAggregateRecordsEmptyName: empty name → zero tokens →
  zero new records.

Binaries rebuilt. Full repo race sweep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last deferred item from the original roadmap. A
daemon started with DHT on now:

  - constructs a *daemon.Bootstrap with the engine's Lookup,
    PointerGetter (which satisfies PPMIGetter), known-good
    Bloom filter, and reputation tracker;
  - kicks off channel A (anchor PPMI fetch) in a background
    goroutine so daemon.New doesn't block on the parallel
    anchor fetch;
  - exposes the bootstrap via d.Bootstrap so HTTP/CLI/GUI
    surfaces can introspect admission state.

Channels B (BEP-51 crawl) and C (peer_announce endorsement
gossip) stay pluggable hooks — the Bootstrap supports them
today; the engine still needs to deliver the respective signals.

### Changes in internal/daemon/daemon.go

- Daemon struct gains `Bootstrap *Bootstrap` field alongside
  the existing subsystems. Nil when Lookup unavailable.
- New branch between companion-subscriber setup and session
  restore constructs Bootstrap when eng.Lookup() != nil.
  Errors are warn-logged but never fatal — a daemon can still
  start without the bootstrap, it just runs without an anchor
  fetch.
- RunAnchors fires in a goroutine so daemon.New stays fast; the
  log line reports (succeeded, errors) once it completes.

### Tests (internal/daemon/bootstrap_wiring_test.go)

- TestDaemonBootstrapNilWithoutDHT: DisableDHT=true → engine
  has no Lookup → d.Bootstrap stays nil. Covers the "DHT-off"
  daemon path.
- TestDaemonBootstrapAttachesWithDHT: Regtest=true + DHT on →
  Bootstrap attaches; fresh AdmittedCount is 0. Confirms the
  wiring is live without depending on hardcoded anchors.

Binaries rebuilt. Full 15-package race sweep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plumbs the Bootstrap orchestrator's counters (anchor pubkeys,
admitted publishers) through the /aggregate endpoint and the CLI
status block so operators can observe channel-A fetch progress
live without log tailing.

### httpapi

- New BootstrapProbe interface in aggregate.go — AnchorCount +
  AdmittedCount. Kept deliberately minimal so the httpapi layer
  doesn't need to import internal/daemon (would create an
  import cycle).
- AggregateStatusResponse gains a nullable `Bootstrap` field of
  type *AggregateBootstrap{Anchors, Admitted int}. Omitted when
  the server has no probe attached.
- Server struct + Options gain a `Bootstrap BootstrapProbe`
  slot; wired into NewWithOptions.

### daemon

- daemon.Bootstrap gains an AnchorCount() method returning
  len(b.anchorKeys) so it natively satisfies BootstrapProbe
  alongside the already-existing AdmittedCount().
- daemon.New passes d.Bootstrap into httpapi.Options.Bootstrap
  when non-nil. Order matters: the Aggregate-bootstrap block
  runs before the HTTP-API block in daemon.New, so the probe
  is set by the time the Server sees it.

### CLI status block

- emitAggregateBlock renders two new rows when
  agg.Bootstrap != nil:
    "bootstrap anchors:    N"
    "bootstrap admitted:   M"
- Rows omitted when the probe is nil — matches the pattern for
  the other optional rows (record source, cache size).

### Tests (6 new across two files)

- httpapi/aggregate_test.go (+2):
  * TestAggregateEndpointReportsBootstrap: fake probe with
    anchors=5, admitted=12 surfaces both values in the JSON.
  * TestAggregateEndpointOmitsBootstrapWhenNil: no probe →
    Bootstrap field omitted from response.
- cmd_status_bootstrap_test.go (+2):
  * TestStatusTextRendersBootstrapBlock: populated probe →
    both lines render.
  * TestStatusTextOmitsBootstrapWhenNil: nil Bootstrap →
    no "bootstrap" lines in output.

Binaries rebuilt. Full repo race sweep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comprehensive summary of the 26 commits on this branch. Intended
as source material for the PR description, release notes, and an
operator-facing overview of what shipped.

Covers:
- New byte formats (PPMI, B-tree index torrent, hashcash).
- New wire protocol (sn_search msg_types 4-8, BitSetReconciliation
  capability bit, RIBLT).
- New runtime components (BTreeReader/BuildBTree, RecordCache,
  SyncSession, PPMIPutter/Getter, Bootstrap).
- Integrations (Engine attaches cache, mints records on torrent-add;
  daemon wires Bootstrap; sync handler queries RecordSource).
- Observability (/aggregate endpoint, CLI/web/GUI status cards).
- Ops tooling (swartznet aggregate build/inspect/find).
- Regression gates (benchmarks, end-to-end integration test).
- Docs updates (05/06/07 + CHANGELOG).

Also enumerates:
- A worked-example CLI exercise showing the offline build/find flow
  and the running-daemon introspection flow.
- Seven explicitly deferred items (empty anchor list, BEP-51 wiring,
  endorsement gossip extraction, production PoW difficulty,
  per-record reputation, OHTTP/PIR, Dandelion++) with pointers to
  PROPOSAL sections that still cover them.
- Migration notes for operators — dual-write/dual-read, no action
  required on upgrade.
- The two rough-edge bugs caught in-development (RIBLT graduated
  rate, FNV-1a key hash).
- Chronological commit table with every hash + subject.

No code changes. Docs only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SPEC §3.3 channel-C wiring: peer_announce frames now carry an
optional `endorsed` list of 32-byte publisher pubkeys the sender
vouches for. The handler routes each entry through a new
EndorsementSink interface which the daemon implements via an
adapter over Bootstrap.IngestEndorsement.

### swarmsearch/wire.go

  - PeerAnnounce gains `Endorsed [][]byte` (bencode key
    "endorsed", omitempty). Capped at MaxEndorsedPerAnnounce
    (= 10, matching SPEC §3.3) on both encode and decode.
  - EncodePeerAnnounce refuses oversized lists and malformed
    (non-32-byte) entries up front.
  - DecodePeerAnnounce silently truncates over-cap lists and
    filters wrong-length entries — malformed entries are local
    validation faults, not protocol violations, so the rest of
    the frame still decodes.

### swarmsearch/protocol.go

  - New EndorsementSink interface with a single
    NoteEndorsement(endorser, candidate) method.
  - Protocol struct gains an endorsementSink field + fine-
    grained locking alongside the existing indexerSink.
  - SetEndorsementSink / EndorsementSink getter/setter.

### swarmsearch/handler.go

  - PeerAnnounce dispatch extended: after the existing indexer-
    sink routing, iterate pa.Endorsed and call
    sink.NoteEndorsement for each valid entry. Drops:
    * Entries with the wrong length (already filtered by
      Decode, defensive here).
    * Self-endorsement (candidate == sender pk).
    * All-zero pubkeys.
  - Endorsements from a peer that did NOT announce its own
    publisher pk are DROPPED — protects the admission policy
    from pubkey-less peers influencing threshold counts.

### daemon/daemon.go

  - New bootstrapEndorsementSink{boot *Bootstrap} adapter
    implements swarmsearch.EndorsementSink.
  - After Bootstrap construction, daemon.New installs the
    adapter via eng.SwarmSearch().SetEndorsementSink. Keeps
    the swarmsearch package free of any internal/daemon
    import (would have been an import cycle).

### Tests (9 new in swarmsearch/endorsement_test.go)

  - Wire: round-trip preserves 32-byte entries; encode refuses
    oversize lists and wrong-length entries; decode filters
    malformed entries and truncates overruns.
  - Handler: routes valid endorsements when sender has pk;
    drops everything when sender has no pk; filters self + zero
    candidates.
  - Protocol: SetEndorsementSink / EndorsementSink round-trip.

Binaries rebuilt. Race sweep clean across the 8 packages
touched; the pre-existing GUI -race flakes observed on CI are
unrelated to this commit (none of internal/gui touched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fyne v2.7.3's theme.DefaultTheme() uses lazy-init; the first two
concurrent callers race on the internal theme cache. Our helper
tests (TestSwartzThemeColours, TestCopyableValue, etc.) that call
DefaultTheme indirectly were marked t.Parallel() and regularly
tripped the -race detector. Observed failure rate ~20-30% across
tail runs; non-deterministic which tests trip.

### Diagnosis

Race captured from the detector:

  Read at 0x000003bccf60 by goroutine 58:
    fyne.io/fyne/v2/theme.DefaultTheme() theme.go:44 +0x675
    gui.(*swartzTheme).Color()           theme.go:54 +0x6ef
    gui.TestSwartzThemeColours()         helpers_test.go:306

  Previous write at 0x000003bccf60 by goroutine 61:
    fyne.io/fyne/v2/theme.DefaultTheme() theme.go:45 +0x5a
    fyne/test.(*configurableTheme).Icon() theme.go:224 +0xaf
    theme.safeIconLookup() icons.go:1373 +0x41
    theme.ContentCopyIcon() icons.go:958 +0x224
    gui.copyableValue()    app.go:316 +0x22f
    gui.TestCopyableValue() helpers_test.go:401

The race window is strictly the first-call init — once the
theme is materialised, subsequent reads are safe.

### Fix

internal/gui/testmain_test.go adds a TestMain that calls
theme.DefaultTheme() and theme.BackgroundColor() serially
before os.Exit(m.Run()). Forces the lazy init chain (theme →
icons → colour palette) to complete in a single goroutine,
eliminating the race window. Parallel tests afterward see a
stable pointer.

Cost: one theme instantiation, ~microseconds.

### Verification

Before: 2-3 fails per 10-iteration batch under -race.
After:  30/30 clean, 10/10 clean (two separate batches).

Also confirmed the full repo race sweep across all 15 packages
runs clean.

No production code touched — fix is test-harness-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every v0.5+ node now automatically advertises the Aggregate sync
capability in its peer_announce. Two running engines see each
other's bit across the LTEP wire — the capability gate in the
sync handler will pass on inbound msg_types 4-8 in real
peer-to-peer traffic, not just unit tests.

### swarmsearch/services.go

  DefaultServices() bitmask now includes BitSetReconciliation
  (bit 9, 0x200) alongside the existing:
    BitShareLocal | BitFileHits | BitContentHits |
    BitCompanionPublisher | BitCompanionSubscriber |
    BitSnippetHighlight

  Resulting bitmask: 0x000000000000002ed.

  Before this change, every v0.5 daemon shipped BUT refused to
  participate in sync sessions — each sync_begin frame was
  gated on Services.Has(BitSetReconciliation), which was only
  set when tests manually registered the bit. This commit
  flips the default so production traffic works too.

### testlab/aggregate_services_scenario_test.go (2 tests)

- TestScenarioBitSetReconciliationAdvertised: spins up a
  2-node cluster, wires the mesh, waits for handshakes, and
  asserts every node sees BitSetReconciliation on at least one
  known peer. Proves the bit actually travels from one
  peer_announce emission to the other side's PeerState.
- TestDefaultServicesIncludesBitSetReconciliation: belt-and-
  braces regression guard — a future refactor that accidentally
  drops the bit from DefaultServices fails this test cheaply.

Observed on run:
  node 0: peer 127.0.0.1:35830 advertises BitSetReconciliation
          (services=00000000000002ed)
  node 1: peer 127.0.0.1:34316 advertises BitSetReconciliation
          (services=00000000000002ed)

Binaries rebuilt. Full 15-package repo race sweep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the initiator-side half of the Aggregate sync protocol.
Before this commit, every node could RESPOND to a sync_begin
but had no way to ORIGINATE one — the capability was
responder-only, making peer-to-peer sync strictly asymmetric
and impossible to use in production.

### New exports

- Protocol.StartSync(peerAddr, filter, localRecords) (*SyncSession, error)
  Creates an initiator-side session, calls Begin(filter) to
  produce the sync_begin frame, registers the session (so
  inbound sync_symbols/records/end route back via the existing
  lookupSyncSession path), encodes + sends the frame, returns
  the session handle. Rolls back session registration on send
  error so the txid slot is free for retry.

- Protocol.SendSyncNeed(peerAddr, sess, ids)
  Encodes + ships a sync_need frame. Caller-convenience wrapper
  so users don't have to reach for the Sender themselves.

- Protocol.CloseSync(peerAddr, sess, status)
  Emits sync_end, marks session Ended, releases from the
  per-peer map. Idempotent.

- Protocol.WaitSyncConverged(sess, timeout) bool
  Poll-convenience for tests and ops tools that don't want to
  roll their own convergence loop.

### Guards

- ErrSyncPeerUnknown / ErrSyncCapabilityMissing / ErrNoSender —
  StartSync refuses early when:
  * The peer is not known to the swarm (no PeerState).
  * The peer's advertised services lack BitSetReconciliation.
  * No Sender is attached (no way to transmit anyway).

### Tests (7 new in sync_start_test.go)

- TestStartSyncUnknownPeer, TestStartSyncPeerMissingCapability,
  TestStartSyncNoSender — each guard fires on the right condition.
- TestStartSyncHappyPath: session created with role=initiator
  phase=Begun, sync_begin frame sent with the session's txid,
  lookupSyncSession finds the same session instance afterward.
- TestStartSyncSenderFailRollsBack: Send error propagates AND
  releases the session so the txid slot is free.
- TestCloseSyncReleasesSession: sync_end frame matches session
  txid, lookup returns nil post-close.
- TestSendSyncNeed: sync_need frame carries the supplied IDs.

recordingSender helper shared across tests captures every
outbound Send for assertion. Keeps the tests free of live
transport concerns — same pattern as the existing Query tests.

Full repo race sweep clean on second run. The first-pass flake
on engine.TestSessionRoundTrip (10 s timeout under concurrent
package load) reproduces neither in isolation nor on retry;
unrelated to this commit.

Binaries rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frank Dosk and others added 28 commits April 25, 2026 04:54
Locks the default-Status branch in EncodeSyncEnd: a SyncEnd
constructed without a Status field gets SyncStatusConverged
promoted in. This is the defence against an operator forgetting
to set Status — a frame on the wire always carries a non-empty
status string.

swarmsearch package coverage 95.7% → 95.8%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests for the dark-theme implementation:

  - Color: walks every named color the switch handles
    explicitly + an unknown name to hit the default-fallback
    arm. Color was at 25% because only a handful of cases
    fire when a real Fyne app renders.
  - Font / Icon / Size: pass-through helpers that delegate
    to theme.DefaultTheme(); just verify they return non-nil
    / non-zero values.

gui package coverage 8.3% → 9.4%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small assertions on the windowForObject helper:

  - Runtime test: windowForObject(nil) returns nil. The
    function takes a fyne.CanvasObject and is called by
    every tab to find its host window; the nil-input guard
    prevents a downstream panic in app.Driver().CanvasForObject.
  - Compile-time: var _ func(fyne.CanvasObject) fyne.Window
    = windowForObject pins the signature so future Fyne API
    drift causes a build failure instead of a silent shift.

Function coverage stays at 25% (full traversal needs a real
Fyne canvas with active app + driver, which costs more setup
than the contract value here justifies). The test exists for
the contract lock, not the coverage number.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four tests for the wire-level guards in DecodeInterior:

  - bad kind (leaf page passed in): only PageKindInterior /
    PageKindRoot are accepted.
  - payload-length-exceeds-page: synthetic page claims
    PayloadLength=60000 in 24 bytes — defends against
    truncated wire frames.
  - payload-too-short: 1-byte payload (less than the 2-byte
    child-count header) is rejected.
  - short separator: 1 child with a 1000-byte varint
    separator length but no separator bytes — "short
    separator or child index" guard fires.

Helper makeInteriorPage builds synthetic pages by hand using
the same encodeHeader the production builder uses.

Companion package coverage 91.0% → 91.1%; DecodeInterior
function coverage 76.9% → ~100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six tests for the wire-level guards:

  DecodeLeaf:
  - bad kind (root page) — only PageKindLeaf accepted
  - 1-byte payload — less than the 2-byte record-count header
  - bad record varint — 4 continuation bytes with no terminator
    trip binary.Uvarint's n<=0 return path
  - short record body — varint claims 1000 bytes, payload has
    fewer

  EncodeTrailer:
  - pageSize < PageHeaderSize+TrailerPayloadSize — rejected
  - TrailerVersion != 0x01 — only v1 supported

Companion package coverage 91.1% → 91.4%; per-function
DecodeLeaf 76.9% → ~100%; EncodeTrailer 76.9% → 92%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests for the pack-time validation arms:

  - empty keyword (r.Kw == "") rejected before bencode runs
  - oversize keyword (>MaxKeywordBytes) rejected before
    EncodeLeaf attempts to fit it

Validating at pack time (not at every per-page Encode call)
saves both error-message clarity and a redundant traversal of
the records list.

Companion package coverage 91.4% → 91.6%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests for the inner-loop error arms of BTreeReader.Find:

  - leaf-fetch error: failOnPagePageSource wraps a real
    BytesPageSource and errors only on a specific Piece(idx)
    call, letting OpenBTree's root + trailer fetches succeed
    while tripping the leaf-fetch arm during Find.
  - leaf-decode error: corrupt the magic bytes of leaf piece
    index 1 after OpenBTree returns; DecodeLeaf rejects and
    Find wraps the error.

Both arms previously relied on the happy path being the only
exercised branch.

Companion package coverage 91.6% → 92.1%; Find function
coverage 77.4% → ~95%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four tests for the per-field length guards in DecodePPMI:

  - IH != 20 bytes (0/10/30): the BEP-3 infohash invariant
  - Commit != 32 bytes (16): SHA256 commit fingerprint
  - Topics != 32 bytes (8): cuckoo-filter digest
  - NextPk != 32 bytes (64): rotation pubkey

Existing tests covered empty + garbage payloads only; these
fill in the per-field validation arms that protect against
malformed PPMI items on the DHT.

dhtindex package coverage 92.9% → 93.8%; DecodePPMI 71.4% → ~100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Direct test on the XDG-path helper used by 'swartznet
aggregate build' when the caller doesn't pass --key. Locks
the "/.local/share/swartznet/identity.key" suffix so the
daemon and the CLI keep agreeing on where the key lives.

cmd/swartznet package coverage 26.5% → 26.8%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drive a fully signed wire SyncRecord through onSyncRecords and assert
the recordSink receives it. Complements the existing rejection-arm
coverage in onSyncRecords with a positive end-to-end test that
verifies signature validation passes and Add fires once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-existing TestNewAnacrolixPutterBadKey passed nil server
alongside a short key, so the nil-server guard tripped first and
the len(priv) != ed25519.PrivateKeySize check stayed uncovered.
New test passes a real isolated DHT server to get past the first
guard and exercise the key-size validation. Bumps dhtindex
package coverage from 93.5% → 93.8%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drive a fetcher that returns non-bencode bytes so
PublisherFromMetainfo's decode fails, exercising the
'case perr != nil' arm that increments Malformed without
calling the sink. Closes the last 0%-covered branch in
CrawlOnce's classification switch; coverage 93.5% → 93.9%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drive a fetcher that cancels the parent context after its first
call so the next iteration's ctx.Err() guard fires and CrawlOnce
returns early with the partial outcome and the cancellation
error. Bumps CrawlOnce coverage from 91.7% → 95.8%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new tests bring SampleInfohashes from 75% → 95.8%:
  - TestSampleInfohashesErrorResponse drives a y:"e" KRPC error
    reply through the responder so res.ToError() returns non-nil.
  - TestSampleInfohashesNodes6 includes a 38-byte compact-IPv6
    entry in the response so the for-range over r.Nodes6 fires.
Total dhtindex package coverage 93.9% → 94.2%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tests cover defensive guards in btree encoders that no
existing test exercised:
  - EncodeInterior wrong-kind + empty-children rejection
  - EncodeLeaf empty-records rejection
  - decodeHeader short-page guard
  - EncodeRecord oversize-keyword guard

Bumps EncodeInterior 85.7% → 92.9%, EncodeLeaf 84% → 88%,
decodeHeader → 100%, and the companion package as a whole
from 92.1% → 92.5%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
makeInteriorPage built pages with Version=0 (zero value), so every
test that called it through DecodeInterior/DecodeLeaf was actually
tripping decodeHeader's version-mismatch check (BTreeVersion=1)
rather than the specific guard each test claims to exercise.
The tests still passed because they only assert err!=nil, but the
coverage gap was real: ~12% statement coverage where the intended
guards are 0%-hit.

Setting Version=BTreeVersion in the helper unblocks every guard:
  - DecodeInterior: 80.8% → 92.3%
  - DecodeLeaf:     80.8% → 92.3%
  - DecodeTrailer:  bumped via the leaf/trailer reuse path
Package coverage 92.5% → 93.1%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new tests close the last gaps in the decoder error path
that the version-zero shadow had been masking:

  - TestDecodeInteriorShortPage / TestDecodeLeafShortPage feed
    a sub-header-sized page so decodeHeader's length guard
    fires and Decode{Interior,Leaf} surface the error rather
    than panic.
  - TestDecodeInteriorBadSeparatorVarint claims 1 child whose
    separator-length varint is 4 unterminated continuation
    bytes (binary.Uvarint returns n<=0).

Also fixes the inline TestDecodeInteriorPayloadTooLong helper
that built its header without setting Version. DecodeInterior
now 100%, DecodeLeaf 96.2%; package 93.1% → 93.4%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand-build a trailer page whose first payload byte is 0x02 and
feed it to DecodeTrailer. EncodeTrailer rejects bad versions
before the round trip, so the only way to exercise
DecodeTrailer's version guard is to construct the bytes
directly. DecodeTrailer 90.6% → 93.8%; package 93.4% → 93.5%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small unit tests close the last branches in the per-session
helpers:
  - Finish("") routes through the SyncStatusConverged default
  - ApplyEnd with a mismatched TxID hits the txid-error arm
    without advancing the phase
  - SetBudgets exercises the (0,0) / (0,N) / (N,0) no-op arms

Each helper goes from 83.3% → 100%; package coverage 95.8% → 96.1%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing TestLoadOrCreateUnreadablePath passes a directory
path, but os.Open on a directory actually succeeds on Linux and
the test ends up tripping the JSON-decode error rather than the
open-error guard the comment claims to exercise. Plant a regular
file mid-path so os.Open fails with ENOTDIR (not ErrNotExist),
exercising the actual fmt.Errorf("trust: open ...") arm.
LoadOrCreate 94.4% → 100%; package 97.0% → 98.5%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing TestScoreOfBranches covers most arms but skipped
the `if returned < 1 { returned = 1 }` divisor-clamp. Add a
counter with HitsConfirmed=1, HitsReturned=0 — this bypasses
the all-zeros default branch (HitsConfirmed != 0) and forces
the inner ratio computation to run with returned=0, exercising
the clamp. scoreOf 90.9% → 95.5%; package 94.7% → 95.0%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-existing TestBootstrapPendingCount stopped before driving
any candidate to admission, so the two `if _, admitted :=
b.admitted[k]; admitted { continue }` arms in PendingCount stayed
uncovered. Extend the test to push cand1 over the
EndorsementThreshold (default 3 distinct endorsers, all "strong"
since no tracker is wired) and assert PendingCount drops cand1
from the pending set even though it remains in endorsements +
observed. PendingCount 83.3% → 100%; package 94.2% → 94.8%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new tests:
  - TestNewBootstrapNilLookup hits the constructor's
    `lookup == nil` early-return.
  - TestIngestEndorsementOnAdmittedFreshEndorser pre-admits a
    candidate via the bloom-policy path so its endorsements
    map is still empty, then drives IngestEndorsement to
    exercise the make([...]) allocation arm in the
    already-admitted branch.

NewBootstrap 80% → 84%; IngestEndorsement 88.9% → 94.4%;
package 94.8% → 95.3%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DefaultBootstrapOptions returns positive values for every numeric
knob, so existing tests bypass the four `<= 0` default-fill
guards and the nil-log substitution. Pass BootstrapOptions{} +
nil log so all five fallback assignments run. NewBootstrap
84% → 100%; package 95.3% → 96.4%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Existing tests index torrents with a single tracker so Bleve
returns h.Fields[fieldTrackers] as a string, matching the
type-switch's first arm. Index a torrent with two trackers so
the same field comes back as []any and exercises the slice
arm's range loop. Index.Search 93.2% → 98.3%; package 93.5%
→ 94.2%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-existing test only exercised the nil-engine and
unknown-infohash error paths. Add a test that drives both
engines through AddMagnet on the same magnet so they share an
infohash, then call AddTrustedPeerEngine and assert no error.
testlab tests already exercise this in integration runs but
their coverage doesn't roll up to the engine package.
AddTrustedPeerEngine 87.5% → 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass a directory path to VerifyFile. os.Open succeeds on a
directory (Linux), but reading from the resulting fd returns
EISDIR, exercising the wrapped "signing: read" error path
that no existing test reached. VerifyFile 87.5% → 100%;
package 92.5% → 93.8%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-create a non-empty directory at the .tmp path so
os.WriteFile fails at truncate-open, exercising the
"signing: write tmp" error arm. The remaining 15.4% gap in
SignFile is the rename-fail arm, which is unreachable when
WriteFile already failed first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claudenstein
claudenstein merged commit 5a53922 into main Apr 25, 2026
1 check passed
claudenstein pushed a commit that referenced this pull request Jul 19, 2026
…tion, sync pump

R4-hunt found 12 more bugs (0 HIGH, 8 MED, 4 LOW — severity plateaued at MED since
round 2). This batch fixes 4 clear, self-contained ones.

#1 [MED] DNS-rebind read leak. withCSRFGuard exempted GET/HEAD/OPTIONS BEFORE the
loopback-Host check the file advertises as its DNS-rebind defense, so a rebind
attacker (evil.com -> 127.0.0.1, fetch http://evil.com:7654/status) could read the
node's torrent list, publisher pubkey, published keywords, and reputation. Fix:
run the Host check for EVERY method; the Origin/Referer cross-origin CSRF check
stays write-only. Matches the documented localhost model (writes already required
a loopback Host). Test cases updated + a GET-rebind case added.

#2 [MED] crawl-probe bound its query socket to 127.0.0.1, so the kernel refused to
send to any non-loopback DHT node — the tool could not probe a single real node
(its entire purpose). Fix: bind 0.0.0.0 like the crawl command.

#3 [MED] watchCompletion added COMPANION bookkeeping-torrent infohashes to the
known-good Bloom on completion, monotonically filling the fixed-size spam-signal
filter (violating companion isolation, eroding Layer-D scoring). Fix: gate the
Bloom auto-confirm on !h.companion (promotion stays unconditional per §6),
mirroring autoIndex's guard.

#12 [LOW] StartSync's registerSyncSession overwrote an incumbent session at the
same txid WITHOUT StopPump (initiator/responder txid spaces collide), orphaning
its pump goroutine. Fix: StopPump the incumbent before replacing, mirroring
registerSyncSessionIfUnderCap.

Both binaries rebuilt; changed packages race-clean; web smoke 21/21; gofmt/vet
clean. Round-4: 4 of 12 fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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