Found while designing the tracked_addresses fix for #766. This is strictly larger than that issue and is not covered by #766, #768, #769 or #770.
The defect
FileStorageProvider holds the whole store as an in-memory snapshot loaded once at connect() (impl/nodejs/storage/FileStorageProvider.ts:70-112), and save() rewrites the entire file on every set() (:218-222, JSON.stringify(this.data)).
So with two provider objects over one dataDir:
B's next set() of any key erases every key A has written since B connected.
Not one key — all of them. Including:
IndexedDBStorageProvider is not exposed to this — every get/set goes to the DB rather than through an in-memory snapshot.
Why it isn't the same bug as #766 item 5
#766 item 5 (tracked_addresses lost update) is a single key with a wholesale write, fixable with a read-merge-write serialized inside one provider instance. That fix is correct and worth having, but it only covers one provider object shared by two Spheres — the normal browser wiring.
This is the other case: two provider objects, where no in-provider chain can help, because each has its own snapshot of the whole file and neither observes the other's writes.
Reach
Any Node consumer that constructs two FileStorageProviders on the same dataDir — the exact shape #766 is trying to make safe ("one Sphere per network, disposable, concurrent instances must not interfere"). docs/MIGRATION-TOKEN-REGISTRY.md:95-100 already notes two providers over one dataDir as an unresolved residual; this is what that residual actually costs.
Options
- Re-read before write in
save() — merge the on-disk object with the in-memory delta rather than overwriting. Costs a read per write; makes concurrent providers safe by construction.
- Enforce one provider per
dataDir — a lockfile or a process-level registry, failing loudly on a second open. Cheaper, and arguably the honest contract, but it turns a silent corruption into a startup error a consumer must handle.
- Per-key files instead of one JSON blob. Biggest change; removes the class of bug entirely.
Option 2 is the smallest thing that stops data loss; option 1 is the smallest thing that keeps the current API working. Worth deciding before #766 is described as delivered, since "concurrent instances must not interfere" is not true while this stands.
Found 2026-09-02 against main @ 74f26e3. Not yet verified by an executed repro — the mechanism is read from source (connect() snapshot + whole-file save()); a two-provider test would confirm it in a few lines.
Found while designing the
tracked_addressesfix for #766. This is strictly larger than that issue and is not covered by #766, #768, #769 or #770.The defect
FileStorageProviderholds the whole store as an in-memory snapshot loaded once atconnect()(impl/nodejs/storage/FileStorageProvider.ts:70-112), andsave()rewrites the entire file on everyset()(:218-222,JSON.stringify(this.data)).So with two provider objects over one
dataDir:Not one key — all of them. Including:
pv2g2:{network}:{pubkey}:delivery-journal— the only place a certified-but-undelivered token's bytes exist (see Money-critical: a pending delivery is stranded on intent completion, and a trust-base rotation freezes the fleet #768.1). Losing it loses a spent token with no error surface.pv2g2:*intent backstop, mint journal, sync cursors, the wallet-api refresh token.mnemonic/master_keyafter a password rotation.IndexedDBStorageProvideris not exposed to this — everyget/setgoes to the DB rather than through an in-memory snapshot.Why it isn't the same bug as #766 item 5
#766 item 5 (
tracked_addresseslost update) is a single key with a wholesale write, fixable with a read-merge-write serialized inside one provider instance. That fix is correct and worth having, but it only covers one provider object shared by two Spheres — the normal browser wiring.This is the other case: two provider objects, where no in-provider chain can help, because each has its own snapshot of the whole file and neither observes the other's writes.
Reach
Any Node consumer that constructs two
FileStorageProviders on the samedataDir— the exact shape #766 is trying to make safe ("one Sphere per network, disposable, concurrent instances must not interfere").docs/MIGRATION-TOKEN-REGISTRY.md:95-100already notes two providers over onedataDiras an unresolved residual; this is what that residual actually costs.Options
save()— merge the on-disk object with the in-memory delta rather than overwriting. Costs a read per write; makes concurrent providers safe by construction.dataDir— a lockfile or a process-level registry, failing loudly on a second open. Cheaper, and arguably the honest contract, but it turns a silent corruption into a startup error a consumer must handle.Option 2 is the smallest thing that stops data loss; option 1 is the smallest thing that keeps the current API working. Worth deciding before #766 is described as delivered, since "concurrent instances must not interfere" is not true while this stands.
Found 2026-09-02 against
main@ 74f26e3. Not yet verified by an executed repro — the mechanism is read from source (connect()snapshot + whole-filesave()); a two-provider test would confirm it in a few lines.