Harness: /tmp/scenario18-harness.mjs (WS-level assertion, pattern
matches #17's /tmp/call-test-harness.mjs).
- A (Pi→VM logged-out callee, Path A zero-ringee):
terminal dm_call_undeliverable reason=no_recipient peerOrigin=VM
elapsed 212ms (budget 2s, pre-fix 60s)
- B (VM→Pi logged-out callee, symmetric):
elapsed 155ms, peerOrigin=Pi
- C (Path B, DM deleted on VM):
relay hits Path B after DB delete, Bob offline,
elapsed 140ms, reason=no_recipient
- D (group DM with online member, non-regression):
VM accepted (Bob rung), no toast on caller
Bob's dm_call_incoming arrived in 144ms
All payloads correct: terminal:true, phase:'start', failures[0].reason
'no_recipient', correct peerOrigin. peerLabel empty because both
instances' federation_peers.instance_name is NULL — pre-existing state,
toast code already falls back to origin hostname (not a #18 concern).
Cannot merge from agent per plan Task 10 Step 7 — coordinator's call.
Follow-up from Task 2 code review. Keeps the test mock aligned with
the widened return type even though vi.mock doesn't structurally
typecheck the factory.
dm-system.md: fold the voice.md cross-reference into the paragraph so it
renders as part of the Cross-instance access explanation instead of an
orphaned bullet.
websocket.md: add 'host_unreachable' to the dm_call_undeliverable phase
union — stale since #32 was merged (docs drift noted in Task 8 review).
federation.md — new undeliverable bucket subsection with three-way
classification table, Path A/B semantics, and wire backward-compat note.
voice.md — no_recipient row in failure-surface table.
websocket.md — DmCallUndeliverableReason union updated to include no_recipient.
dm-system.md — cross-reference to voice.md for no_recipient reason.
buildCallUndeliverableToast renders "{peerLabel} couldn't ring anyone."
for the single-failure terminal case; multi-failure + non-terminal paths
fall through to existing lines (which already fold the new reason in by
peer label). TDD — four new assertions.
sendFederatedCallStart now treats a 200-with-undeliverable-messageId as
a peer-failure instead of unconditional success. Feeds the existing
failures[] array and terminal-determination machinery from #16.
New sendFederatedCallStartForTest export mirrors the existing
handleDm*ForTest pattern. TDD — three tests cover single-peer terminal
no_recipient, group-DM mixed delivered+undeliverable non-terminal, and
the happy-path (empty undeliverable → no event).
Also hardens sendCallRelay's response parse: validates undeliverable
is an Array and entries are well-shaped, logs protocol drift at warn/debug
rather than silently falling back to old-peer semantics.
CallRelayResult success arm gains undeliverable: string[]. sendCallRelay
parses FederationRelayResponse.undeliverable (when present) and returns
the messageIds so sendFederatedCallStart can reclassify per-peer results.
Old peers that omit the field → empty array → today's behavior.
TDD — three tests cover old-peer, new-peer-with-undeliverable, and 5xx paths.
processDmCallStartEvent Path A now skips offline local members (matching
Path B's pre-existing per-member check) and pushes undeliverable when no
member could be rung, instead of creating a stranded FederatedCallEntry.
TDD — two new tests cover zero-online and mixed-online cases.
processDmCallStartEvent Path B no longer silently accepts when no local
participant is reachable. Pushes {messageId, reason: 'no_recipient'} to
the undeliverable ack bucket so the caller can surface fast-fail.
TDD — test asserts undeliverable push + no FederatedCallEntry.
Additive plumbing. No behavior change — every existing event-type path
continues to push to accepted/rejected only. Response serializes the new
bucket only when non-empty (byte-identical responses in the normal case).
Tasks 3-4 add actual undeliverable pushes for dm_call_start paths.
Additive protocol extension. No consumers yet — follow-up commits wire
the new bucket into the relay endpoint, sendCallRelay, sendFederatedCallStart,
and the toast copy.
The admin reset endpoint (DELETE-pattern gated on peer.status !== 'needs_attention')
doesn't transition status — it deletes the row of an already-deactivated peer.
onPeerDeactivated already fired at the earlier needs_attention transition, so
the reset site correctly has no hook. The enum value was defensive-unused; per
project principles (no backwards-compat shims, no placeholders) drop it.
Admin-revoke endpoint (DELETE /api/federation/peers/:id): fires
onPeerDeactivated(id, 'admin_revoked') after the status write to
'revoked', evicting any in-flight federated calls for the now-revoked
peer.
Admin-reset endpoint (POST /api/federation/peers/:id/reset): hook
SKIPPED. The reset endpoint is guarded to only run when status is
already 'needs_attention' (active peers are rejected at the boundary
with a 400). Because the peer was already deactivated before reset is
called, onPeerDeactivated was already fired at the active→needs_attention
transition. The reset deletes the row entirely rather than writing a new
status; it does not represent a transition OUT OF active, so wiring it
here would be a semantic error — double-evicting an already-deactivated
peer.
Code-review catch: the Path-2 accept-rollback previously emitted
dm_call_undeliverable { terminal: true } via sendToFederatedCallUsers,
which broadcasts to every ringedUserIds entry. In a group DM this
would prematurely tear down non-accepting ringees whose own accept /
reject / timeout paths should govern their state. Switch to
sendToUser(acceptorId) so only the acting user gets the terminal
signal. Reorder the clearFederatedCall to happen before the emit so a
concurrent end-handler sees a cleared entry (clearFederatedCall is
idempotent). Spec updated, test extended to assert the scoping with a
two-ringee group-DM fixture.
Close backlog #30: second-pass heal that DROPs and rebuilds empty
drifted tables to reconcile pre-rename column drift on old pre-drizzle
dev DBs (companion to #29). `healInitialSchemaDrift` handles
addable-column drift; this handles the NOT-NULL-no-default case that
heal-by-ALTER can't resolve — e.g. federation_outbox's
`message_id`/`dm_channel_id` that were renamed to
`entity_id`/`context_id` in the pre-drizzle manual migrate.ts.
Rebuild target is the current-migration-state snapshot (derived from
__drizzle_migrations ↔ _journal.json by `when` timestamp), not the
latest on disk — rebuilding forward would duplicate-column with
pending ALTER TABLE ADD COLUMN statements drizzle is about to run.
Empty-table gate preserves data. Missing-column gate (not extra-only)
avoids touching unused leftover columns from pre-drizzle manual
migrations that no current code reads.
Verified against three simulated scenarios (fresh / pre-drizzle
drift / production-like) plus live boot on the actual affected dev
DB: server binds :3005 cleanly, no outbox worker SQLITE_ERROR ticks,
migrations complete silently. Server tests 110/110, web 131/131.
No migration files changed. Deployed instances unaffected. Skip
redeploy.
Companion to #29. `healInitialSchemaDrift` can only ADD columns, so it
skips NOT NULL-without-default columns like `federation_outbox.entity_id`
/ `context_id` — which on some old pre-drizzle dev DBs carry the
pre-rename names `message_id` / `dm_channel_id` instead. The tables
load but the outbox worker fails every tick with "no such column:
federation_outbox.context_id" once the server is up.
Adds healRenamedColumns() — a second pass that runs right after
`healInitialSchemaDrift`. For each table whose physical column set is
*missing* columns declared by the current-migration-state snapshot AND
which holds zero rows, it DROPs the table and rebuilds it from the
snapshot's JSON: columns, defaults, foreign keys, composite PKs,
unique constraints, indexes.
Key design decisions:
- **Target is the current-migration-state snapshot, not the latest on
disk.** The current state is determined by the highest
`__drizzle_migrations.created_at` matched against `_journal.json`'s
`when` timestamps (with backward walk for idx values that lack a
snapshot, like the hand-written 0002). Rebuilding to a *future*
snapshot would introduce columns that drizzle's migrator is about
to add via ALTER TABLE ADD COLUMN, causing duplicate-column errors.
Rebuilding to the *current* snapshot preserves the invariant that
drizzle's pending migrations can run cleanly afterwards.
- **Missing-column gate, not extra-column.** Extra columns alone don't
break anything at runtime (the ORM ignores them); they're leftover
from pre-drizzle manual migrations and might matter to the operator.
Missing columns DO break runtime queries, so only those trigger
rebuild.
- **Empty-table gate.** Non-empty tables log a warning and skip —
data preservation wins over heal, and this path should only ever
hit a pre-drizzle dev DB that never exercised the affected tables
in the first place.
- **Transactional rebuild.** DROP + CREATE + index reinstatement wrap
in a single `db.transaction()` so a partial rebuild rolls back.
Verified against three scenarios via in-memory simulation:
(A) fresh install — heal no-op, drizzle creates everything; (B) pre-
drizzle dev DB with fed_outbox/fed_mutation_log rename drift —
tables rebuilt to 0000 snapshot, drizzle then applies 0001–0004
successfully to reach the current target schema; (C) post-migration-
correct (production-like) — heal no-op, drizzle no-op, schema
unchanged. Live boot on my actual dev DB: migrations complete
silently, server binds :3005, no outbox worker errors. Server tests
110/110, web 131/131, typecheck clean.
No migration files changed. Deployed Pi+VM instances are unaffected
(their schema matches the snapshot exactly — heal won't touch
anything).
Closes backlog #30.
Two small items from the S2S DM unification backlog.
#28 — 4 stale JoinSpace test assertions (test-only fix)
Assertions from the old JoinServer component drifted when the file was
renamed (fc06e25) without being updated: placeholder text, submit
button now disables-when-empty (so the 'Invite code is required' error
path is unreachable from the rendered form), and joinByCode signature
gained a second `origin` argument. Updated each assertion to match
current behavior. No code change. 131/131 web tests now pass.
#29 — Local dev DB missing federation_peers.remote_max_upload_size
Root cause: baselineExistingInstall trusts that any pre-existing
install's schema matches 0000_initial. That breaks for dev DBs from
the pre-drizzle manual migrate.ts system that didn't ran every
idempotent ALTER — 0000 gets marked applied without its columns
actually existing, and a later migration that recreates the table
(0004_cooing_black_knight) crashes. New healInitialSchemaDrift()
walks 0000_snapshot.json, and for each existing table ADDs any
declared-but-missing columns before drizzle's migrate() runs.
Idempotent; production instances are no-op.
Remaining dev-DB drift flagged for a follow-up: federation_outbox and
federation_mutation_log retain pre-rename column names (message_id /
dm_channel_id) that the old manual system renamed to entity_id /
context_id. Both tables are empty on affected DBs, but the clean fix
requires DROP + RECREATE with index reinstatement, beyond #29's
scope. Outbox worker emits SQLITE_ERROR ticks post-boot on unfixed
dev DBs; separate item.
No migration files changed. Deployed instances are unaffected by
either commit (#28 test-only, #29 heals only DBs missing columns —
production DBs aren't missing any). Skip redeploy.
baselineExistingInstall marks 0000_initial as applied when it detects
pre-existing tables, on the assumption the install's schema matches the
0000 baseline. That assumption is false for dev DBs created under the
pre-drizzle manual migrate.ts system that skipped or never ran some of
its idempotent ALTER TABLE steps — for example the b9e4c65 migration
that added federation_peers.remote_max_upload_size. On such DBs, 0000
is marked done without the column actually existing, and a later
migration that recreates the table (0004_cooing_black_knight)
subsequently crashes with "no such column: remote_max_upload_size"
while building its __new_federation_peers SELECT.
Adds healInitialSchemaDrift(): walks every table in 0000_snapshot.json,
and for each table that already exists, ADDs any columns the snapshot
declares but the physical table is missing. Runs immediately after
baselining, before drizzle's migrate() — so later migrations find the
schema they expect. Columns that SQLite's ALTER TABLE ADD COLUMN can't
safely express (PRIMARY KEY; NOT NULL without a default) are skipped
with a warning rather than corrupting data.
Idempotent: on fresh installs and correctly-migrated DBs every column
is already present, so the loop is a no-op. Production Pi+VM instances
are unaffected.
Verification: local dev DB that previously crashed on 0004 now boots
cleanly — federation_peers gained remote_max_upload_size, nonce_supported,
pending_hmac_secret, secret_rotation_at, secret_rotated_at, and
auto_rotate_interval_days; __drizzle_migrations advanced from 4 to 5
entries; server binds :3005. 110/110 server tests + 131/131 web tests
still pass.
Not covered: a deeper drift on federation_outbox /
federation_mutation_log where the physical tables retain pre-rename
column names (message_id / dm_channel_id) instead of the current
entity_id / context_id. Heal skips those (NOT NULL without default)
and the outbox worker emits SQLITE_ERROR ticks post-boot. Both tables
are empty on affected dev DBs, but a clean fix requires DROP +
RECREATE with index reinstatement which is out of #29's stated scope
("column existing"). Flagged for a follow-up.
Closes backlog #29.
Four assertions drifted from the current JoinSpace modal, carried over
when the file was renamed from the old JoinServer component in fc06e25
without being updated:
- Placeholder was expanded to cover URL-form invite input
('e.g. abc123' → 'e.g. abc123 or https://instance.com/join/abc123').
- 'shows validation error when submitting empty code' asserted a code
path that no longer exists: the submit button is now disabled when
the trimmed input is empty (JoinSpace.tsx line 166), so clicking it
is a no-op and the 'Invite code is required' error from the parser
is unreachable from the rendered form. Replaced with an assertion
that the button is disabled while the input is empty — the actual
validation UX.
- joinByCode signature took on a second `origin` argument during the
S2S DM unification + federated-join work (spaceStore.ts line 69).
parseInviteInput returns { code, origin: undefined } for a bare
code, so the call is `joinByCode('my-invite-code', undefined)`.
Assertion updated to match exactly.
No code behavior change — tests now reflect actual behavior, which
was already correct and deployed. Closes backlog #28.
Close backlog #27: extract cross-store resolvers into a neutral utility
(packages/web/src/utils/crossStoreResolvers.ts) to break a TDZ cycle
between spaceStore and instanceStore. instanceStore's top-level
setResolver calls used to race with spaceStore's `let _getApiForOrigin`
declaration when the module graph was entered from instanceStore
(JoinSpaceModal → useInstanceStore), crashing with "Cannot access
'_getApiForOrigin' before initialization" and preventing
InviteModal.test.tsx and JoinSpace.test.tsx from loading.
Moves the three resolver lets + setters + pure getters + the
WS-populated user-ID cache into the utility; spaceStore re-exports the
public surface; instanceStore imports the setters directly from the
utility (re-exports do not resolve at module-init time under vite-ssr
in the cycle). authStore-using wrappers (resolveUserOrigin,
getLayoutHomeOrigin, getMyUserIdForOrigin) stay in spaceStore but
delegate to the utility.
Also adds the AudioManager mock to InviteModal/JoinSpace test files
(established pattern) so their suites can load.
Verification: typecheck clean, 127/131 web tests pass (up from 121/121;
+6 newly unlocked), server 90/90 unchanged. The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (placeholder
expanded, submit button now disable-when-empty) — unrelated to this
work, made visible only because the suite loads now.
Spec: docs/systems/client-federation.md updated.
Smoke test: vite dev bundle serves spaceStore + crossStoreResolvers
clean; full live E2E blocked by a pre-existing local DB-migration
error unrelated to this client-side refactor (reproduces on main).
instanceStore registers three resolver functions at module load —
setApiForOriginResolver, setUserIdForOriginResolver,
setOriginFromHostnameResolver — whose backing `let` bindings used to
live in spaceStore. When the module graph was entered from
instanceStore (e.g. JoinSpaceModal importing useInstanceStore) the
order became spaceStore → chatStore → useWebSocket → socialStore →
instanceStore (top-level setter call) while spaceStore was still
paused on its line-8 chatStore import, so the backing `let` had not
been reached yet and the setter crashed with
`Cannot access '_getApiForOrigin' before initialization`. This left
InviteModal.test.tsx and JoinSpace.test.tsx unable to even load their
suites once AudioManager was mocked away.
Move the three `let` bindings, their setters, their pure getters, plus
the WS-populated user-ID cache (`_myUserIdByOrigin`, setMyUserIdForOrigin,
getCachedUserIdForOrigin, clearMyUserIdCache) into
`packages/web/src/utils/crossStoreResolvers.ts`. The utility imports
nothing from `./stores/*`, so no back-edge exists. spaceStore re-exports
the public surface for backward compatibility with the many existing
import sites; instanceStore imports the setters directly from the
utility (the in-cycle re-export path does not resolve at module-init
time under vite-ssr, so a direct import is required for the top-level
setter calls).
spaceStore's remaining wrappers (resolveUserOrigin, getLayoutHomeOrigin,
getMyUserIdForOrigin) stay where they are — they combine the utility's
pure lookups with authStore state — but now delegate to the utility.
Also adds the AudioManager mock to InviteModal.test.tsx and
JoinSpace.test.tsx so their suites actually load (same pattern already
used in 5 other test files). Net test-suite result: 127/131 pass (up
from 121/121 — +6 newly unlockable). The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (the placeholder was
expanded and the submit button was made disable-when-empty) made
visible by the suite now loading; they're orthogonal to this change
and handed back for a separate triage.
Closes backlog #27.
Triage and fix of 12 pre-existing test failures on main (flagged by
the #10 DM origin failover merge, commit ff39ab0).
Two root causes:
1. Node 20+'s built-in localStorage/sessionStorage stub shadows jsdom's
working implementation because vitest's populateGlobal doesn't
overwrite globals outside its known allow-list. Any zustand persist
store threw "storage.setItem is not a function". Polyfilled with an
in-memory Storage in src/test/setup.ts. Resolves 11 of 12 failures
(10 keybindStore + 1 FriendsPage toast).
2. FriendsPage "Message button" DM test carried a stale two-arg
assertion that predated the 2026-04-01 federation refactor (commit
7f3ca4e) which dropped the second argument from addDmChannel.
Dropped the trailing '' so the assertion matches current behavior.
Hand-backs (not touched on this branch):
- InviteModal.test.tsx and JoinSpace.test.tsx still fail to LOAD (not
in the 12 tests but flagged by #10's merge note). After stubbing
AudioManager a second blocker surfaces: TDZ error on _getApiForOrigin
in spaceStore.ts:961, caused by a circular-import init order between
spaceStore and instanceStore (via socialStore → useWebSocket →
voiceStore). Federation-adjacent — filed as backlog item for a
structural fix.
Server: 90/90 unchanged. Web: 121/121 pass (was 109/121), 2 suite
loads still failing (tracked).
The DM "Message button" test asserted addDmChannel was called with two
arguments — the channel and an empty-string origin — but the assertion
has been stale since commit 7f3ca4e ("route DM creation to home instance
with federated identity", 2026-04-01). That refactor made FriendsPage
always route DM creation through the home api client and dropped the
second argument from the addDmChannel call because the remote friend's
instanceOrigin no longer applies — home-created DMs don't need a
channelOriginMap entry (lookups default to '' for missing keys; remote-
delivered DMs still get their origin tagged by useWebSocket).
The two-arg assertion was introduced on 2026-03-25 (commit 277b69a)
against an intermediate form of the code that was later rewritten. Drop
the trailing '' so the assertion matches the current, intentional
one-arg call.
Node 20+ ships a built-in localStorage/sessionStorage stub on globalThis
that throws "storage.setItem is not a function" unless Node is launched
with --localstorage-file=PATH. Vitest's jsdom env only overwrites a
fixed allow-list of globals, and neither storage is in that list — so
Node's broken stub shadows jsdom's working implementation and crashes
any code using zustand's persist middleware.
Override both globals in the test setup with an in-memory Storage
implementation. Resolves 10 keybindStore failures plus 1 FriendsPage
toast failure (all symptoms of the same root cause).
The remaining FriendsPage DM assertion failure is unrelated and is
left for follow-up triage as it touches federation DM-creation
behavior.
Client-side DM origin failover on WS disconnect (backlog #10).
When a remote instance's WebSocket drops mid-session, every DM pinned to
that origin is re-keyed to a connected sibling that mirrors the same
federated DM via S2S replication. Covers the channel-ID-per-origin
reality (each instance assigns its own local Snowflake; only federatedId
is shared) by keeping a `dmAlternatives: Map<federatedId, Map<origin,
localChannelId>>` on spaceStore, populated by every `ready` payload
regardless of dedup outcome. On transition, `rekeyDmChannel` atomically
renames the DM across spaceStore (dmChannels / channelOriginMap /
channelLastMessageIds / dmAlternatives), chatStore (messages / hasMore /
scrollPositions / channelAccessTimes / typingUsers / readStates /
unreadChannels / currentChannelId), and the URL (history.replaceState
when viewing the rekeyed DM). Triggers: setInstanceStatus on
connected→disconnected|error, and disconnectInstance / forceRemoveEntry
before removeInstanceSpaces. Voice state (activeDmCall / outgoingCall /
incomingCall) is intentionally not rewritten — LiveKit rooms can't
migrate across origins.
As an in-scope adjacent fix (§3.11 of the spec), `dm_message_created`
now consults `dmAlternatives` before its legacy 2-member-identity
fallback via a new `resolveDmChannelId(rawId)` helper. This closes a
pre-existing phantom-sidebar-entry bug for group DMs in multi-instance
sessions and handles post-failover routing when the reconnected
original origin's WS still addresses the DM by its old local id.
Live verification on Pi+VM (youruser@nova.ddns.net with Orbit.Backspace
as remote): (1) baseline — DM pinned to home, WS drop of remote is a
no-op, reconnect clean, no flap; (2) forced-rekey path — WebSocket
construction delayed on wss://nova.ddns.net via a client-side patch
so orbit's `ready` arrived first, pinning the Nova DM to orbit with
orbit's local id. Stopping the orbit container triggered
failoverDmOriginsFromDisconnected; URL auto-swapped from
`/channels/@me/<orbit-local-id>` to `/channels/@me/<nova-local-id>`
via history.replaceState, the chat view re-fetched from nova via the
new primary id, and subsequent message sends routed to nova. Restart
of orbit left the pin on nova — no re-home flap (§3.6). Group-DM
phantom fix covered by unit tests (9 in dmOriginFailover.test.ts plus
contract test); not exercised live because it requires concurrent
delivery from the non-primary origin's WS, which the forced-rekey
session didn't naturally produce.
Design: internal notes
Plan: internal notes
Pre-existing test failures on main (keybindStore, FriendsPage,
InviteModal, JoinSpace — 12 tests) are unchanged by this branch.
New subsection under client-federation.md's origin-aware routing section
covering dmAlternatives, failoverDmOriginsFromDisconnected, rekey flow,
trigger points, the intentional cache-flush trade-off, voice-out-of-scope,
no-re-home policy, and the WS routing contract. dm-system.md gets a
one-line cross-reference from the Client routing bullet.
Adds a new resolution step before the legacy 2-member-identity fallback:
if the event's dmChannelId is an alternate-origin local id for a DM
whose primary is in dmChannels, route the message to the primary via
resolveDmChannelId. Covers 1-on-1 AND group DMs uniformly — closes a
pre-existing phantom-sidebar-entry bug for group DMs in multi-instance
sessions and handles post-failover routing when the reconnected original
origin's WS still addresses the DM by its old local id.
disconnectInstance and forceRemoveEntry now run
failoverDmOriginsFromDisconnected BEFORE removeInstanceSpaces so any DM
with a connected sibling survives the disconnect via rekey; only DMs
without alternatives are cleared alongside the rest of the instance.
Switched setInstanceStatus to the same static import (dmOriginFailover
lazily reads store state, so no import cycle).
When an instance transitions from 'connected' to 'disconnected' or
'error', fire failoverDmOriginsFromDisconnected for that origin. Dynamic
import preserves the circular-dep-safe resolver pattern used elsewhere
in instanceStore. Fire-and-forget; the failover utility reads fresh
state at call time.
failoverDmOriginsFromDisconnected(origin) walks pinned DMs and re-keys
them to a connected sibling origin's local channel id (via dmAlternatives
federatedId lookup). Preference: home first, then any connected remote in
insertion order. rekeyDmChannel performs the atomic rename across
spaceStore (dmChannels / channelOriginMap / channelLastMessageIds /
dmAlternatives), chatStore (via rekeyChannelState), and the URL (via
history.replaceState when viewing the rekeyed DM). Voice state is
intentionally untouched — LiveKit sessions can't migrate across origins.
Old origin's local id is retained in dmAlternatives for possible later
fail-back without another ready round-trip.
Resolves any raw DM channel ID (primary or alternate-origin local ID)
to its primary dmChannels entry via dmAlternatives federatedId lookup.
Returns null for unknown IDs. Used by the dm_message_created handler
in a later commit to prevent phantom sidebar entries from alternate-
origin deliveries (closes a pre-existing group-DM bug and supports
post-failover routing).
Deletes every channel-keyed entry under oldId (messages, hasMore,
typingUsers, readStates, channelAccessTimes, scrollPositions) without
seeding newId — subscribers refetch naturally from the new origin.
Transfers unreadChannels membership only if oldId was already unread
(mirror state, don't over-badge). Updates currentChannelId if it
matched oldId. Groundwork for DM origin failover rekey.
Drops the given origin from every inner (origin→localId) map; removes
the outer federatedId entry when its inner map becomes empty. Keeps the
store from accumulating stale origin references across long sessions
with connect/disconnect churn.
Every DM arriving in a ready payload with a federatedId now gets its
(origin, localChannelId) pair recorded in dmAlternatives, regardless of
whether the dedup pass kept this copy in dmChannels. Enables client-side
DM origin failover: when the primary origin drops, we can look up an
alternate origin's local channel ID for the same federated DM.
Prep for #10 (DM origin failover on disconnect).
Outbox worker treats `reason: 'duplicate'` as effectively-accepted:
deletes the entry rather than retaining for retry. Duplicate is a
terminal signal (the peer already has the message — retrying will
fail identically forever until TTL expires). Logged at info level
to distinguish from retained-for-retry warnings.
Other rejection reasons (attribution_mismatch, processing_error,
etc.) stay on the retry path; treating additional reasons as
terminal is deferred until observed accumulating.
Discovered during post-#10b deploy verification: Pi had a stuck
outbox entry retrying a message VM already had, logged every
outbox tick. This patch fixes that class of issue.
Duplicate rejection means the peer already has the message (e.g.,
delivered earlier via outbox AND pulled via sync in the same
window). Retrying will fail identically forever until TTL expires.
Before this patch: duplicate-rejected outbox entries were retained
with attempts++ and exponential backoff, creating log noise and
outbox bloat for up to 30 days.
After: duplicate-rejected entityIds join the terminal set alongside
accepted ones and are deleted from the outbox. Logged at info level
('outbox entry removed (terminal)') to distinguish from warn-level
transient-rejection retries.
Other rejection reasons (attribution_mismatch, processing_error,
etc.) stay on the retry path; some may also be terminal but are
deferred until observed accumulating.
Closes#25 (poison-pill event blocks sync forever).
syncPeerMutationLog now replays incoming events one-by-one inside
the pagination loop with try/catch per event. On exception:
console.error with event type, messageId, timestamp, peer origin,
and error message; continue with next event. lastSyncedAt advances
normally at end of all three passes, so a single failing event no
longer blocks catch-up forever.
Final 'replayed N events' log line reports '(K skipped due to
errors)' suffix when K > 0. Trade-off documented in
docs/systems/federation.md: forward progress prioritized over
strict at-least-once delivery.
Replace the batch-level processRelayEvents call with a per-event
loop wrapped in try/catch. On exception: log event type, messageId,
timestamp, peer origin, and the error message; continue to the next
event.
Previously, a single poison-pill event (e.g., UNIQUE conflict from
a malformed relay payload) would throw, be caught by the outer
try/catch, and block lastSyncedAt from advancing — causing every
future activation to retry the same broken window indefinitely.
The final 'replayed N events' log line now reports '(K skipped due
to errors)' when K > 0, surfacing the count to operators. Individual
event failures are logged via console.error with enough context to
debug or replay manually.
Trade-off documented in docs/systems/federation.md: forward progress
of the sync pipeline takes priority over strict at-least-once
delivery. An event that fails to process is lost to the receiver
unless replayed manually.
Closes #10b (S2S outbox sync recovery after peer state transitions).
Unifies three related bugs under a single on-peer-activation handler:
- Stranded outbox backoff after unreachable→active recovery
- Runtime sync only firing at startup (not on runtime peer re-creation)
- Silent enqueue failure for awaiting_approval / needs_attention peers
Plus:
- Mutation log coverage extended to dm_close/reopen, read_state_update,
profile_update, and file_rejected (previously bypassed appendMutationLog)
- /api/federation/sync response builder gains serializers for those 5
event types and a new contextType='profile' branch
- queueOutboxEvent rewritten with explicit per-status handling, mid-call
race catch, and compile-time exhaustiveness check
- Pre-existing gap fixed: ensurePeered now handles needs_attention
explicitly instead of falling through to auto-healing handshake
Verified end-to-end on Pi + VM across all three manual integration
scenarios (unreachable recovery, awaiting_approval drain, post-Reset
catch-up via mutation log).
Discovered during live verification of #10b Scenario 3: the switch
in ensurePeered had no case for needs_attention, so it fell through
to performHandshake. Because the /peer/accept idempotent-200-no-update
safeguard covers needs_attention on the inbound side, the remote
returned 200 without writing the new secret, and performHandshake
transitioned the local peer to 'active' on the 200 response — auto-
healing a state that requires admin intervention.
Affected paths: sendCallRelay non-blocking warm-up (used by typing
relay); any future caller of ensurePeered on a needs_attention peer.
Not affected: resolvePendingPeers (already filters on status='pending').
Fix: explicit case 'needs_attention' returning { status: 'rejected',
error }. Caller observes the rejection and does not advance state.
Adds 'Peer Activation Recovery' subsection with call-site roster,
peer-state x outbox-enqueue x recovery matrix, mutation log
coverage table, /api/federation/sync contextType filter values,
and a Known Issues note about the poison-pill edge case.
Also updates stale references to runInitialSyncForNewPeers (removed
in commit 02a1ed7) to point at the unified startupBootstrapSync
path.
Adds contextType='profile' query branch. Extends the DM-pass
mutation_type IN-clause to include dm_close, dm_reopen,
read_state_update, file_rejected (events with no associated
dm_messages row). Builds channelFederatedIdMap for O(1)
federatedId resolution. Adds serializer branches for all five
new event types (dm_close/dm_reopen, read_state_update,
file_rejected, profile_update) that emit FederationRelayEvent
objects compatible with the existing inbound processors.
Inbound processors (processDmCloseEvent, processDmReopenEvent,
processReadStateUpdateEvent, processProfileUpdateEvent,
processFileRejectedEvent) already exist; sync replay feeds
events through processRelayEvents without any new receive-side
code.
Four event types previously bypassed appendMutationLog, making
them unrecoverable via /api/federation/sync after peer inactivity:
- queueDmCloseRelay (dm_close, dm_reopen)
- queueReadStateRelay (read_state_update)
- handleSizeRejection in federationWorker (file_rejected)
- profile PATCH route (profile_update) — two call sites,
one appendMutationLog per profile change (not per target origin)
The /api/federation/sync response builder is extended to
serialize these event types in the next task.
Every code location that sets federation_peers.status='active'
now invokes onPeerActivated(peerId, reason). HTTP handler sites
use fire-and-forget (.catch(log)) so the response isn't blocked
by sync-pull pagination. The worker-internal health-check site
awaits the handler since the tick is already async.
Sites: /peer/initiate, /peer/accept (4 branches), /approval-
requests/:id/approve, health check recovery, ensurePeered/
performHandshake.
Replaces the silent UNIQUE-swallow placeholder branch. Each peer
status has an explicit branch:
active/pending/unreachable: race-catch — re-fetch peer row and
enqueue via matchedPeers. Previously skipped silently, losing
real-time delivery under asymmetric failure.
awaiting_approval/needs_attention/rejected/revoked: drop with
logged reason. Mutation log still captures; sync-pull on
activation replays.
default: exhaustiveness check (no 'as never' cast) — TypeScript
enforces that every status value is handled explicitly.
Missed in 02a1ed7. The new sync-pull path in federationPeerActivation.ts
uses a dynamic import of processRelayEvents from routes/federation.js;
the static import in federationWorker.ts is no longer used after
runInitialSyncForNewPeers deletion.
The per-peer sync body is now syncPeerMutationLog (in the new
peer-activation module), invoked via onPeerActivated. The startup
path scans for status='active' AND lastSyncedAt=0 and calls the
unified handler for each — same trigger condition as before, unified
code path with runtime transitions.
Two-invariant handler: resetOutboxBackoff + syncPeerMutationLog.
In-flight map keyed by peerId coalesces concurrent activations —
a second call for a peer whose activation is still running shares
the same promise. Errors are swallowed and logged — the handler
never throws so fire-and-forget callers at HTTP handler sites
are safe.
- Add pagination-advance test: verifies since=checkpoint on second
iteration within a pass, and each pass re-seeds since from
peer.lastSyncedAt (not carried from prior pass).
- Eliminate four peer! non-null assertions by capturing the narrowed
value in activePeer after the guard.
- Tighten bodyObj type from Record<string, unknown> to a local
SyncRequestBody type alias.
- Drop the no-op federationRelayEnabled UPDATE in test beforeEach
(default is already 1 per baseline migration).
Three-pass pull-sync (dm, friend, profile) from peer's
/api/federation/sync endpoint, paginated. Seeds sinceTimestamp from
peer.lastSyncedAt so a recovered peer pulls only the delta. Updates
lastSyncedAt to Date.now() on full success; leaves it untouched on
transient failure so the next activation retries the same window.
Replaces the body of the soon-to-be-removed runInitialSyncForNewPeers.
Unconditionally resets nextRetryAt=now and attempts=0 for all
outbox entries of the given peer. No WHERE filter on nextRetryAt —
resetting attempts=0 on already-eligible rows is the correctness fix:
without it, a previously-failed entry keeps stale attempts, and its
next failure uses BACKOFF_SCHEDULE_MS[attempts] (5min to 24h) on a
peer that just recovered.
Empty stubs for onPeerActivated, resetOutboxBackoff, syncPeerMutationLog,
and startupBootstrapSync. Functions are filled in by subsequent tasks
following TDD cycles.
/peer/initiate checked `response.ok` to decide whether to activate the
local peer. `response.ok` is true for the full 2xx range, so a remote
that returned 202 (queued for admin approval — autoAcceptPeering off
on their side) caused the local peer to flip to `active` while the
remote had us `awaiting_approval`. The split only self-healed when
the remote admin approved and pushed us an `awaiting_approval → active`
override via the peer_approval_requests inbound path.
The auto-peer flow in federationPeering.ts:performHandshake already
had the correct 202 branch: set local status to awaiting_approval,
broadcast federation_peers_changed, surface a pending outcome. Mirror
it here:
- Check response.status === 202 BEFORE the !response.ok branch so the
fall-through can't reach the activation code.
- Transition local peer to awaiting_approval (not active).
- Broadcast federation_peers_changed so other admin tabs refresh.
- Return 202 with the sanitized peer so the client observes the
queued state distinctly from both success and failure.
Also added the missing federation_peers_changed broadcast on the
activation (200) path for parity with every other peer-state-change
site in the codebase — it was a pre-existing drift that would leave
sibling admin tabs stale after an initiate. Pattern-aligned with
federationPeering.ts:160 and the rest of routes/federation.ts.
Docs: expanded Phase 1 bullets in docs/systems/federation.md to cover
the 200 / 202 / other non-2xx / network-error branches explicitly and
reference the mirrored auto-peer branch.
Verified: pnpm -r typecheck clean (shared + server), vitest 70/70
pass.
Closes#21 from S2S DM unification backlog.
The three typecheck failures in ws/events.ts for DmCallUndeliverable{Failure,Reason}
and 'dm_call_undeliverable' looked like missing exports from @backspace/shared,
but the types are fully defined and exported in packages/shared/src/types.ts
(lines 361, 367, 429). The real cause was architectural.
packages/server/tsconfig.json declared:
"references": [{ "path": "../shared" }]
TypeScript project references make the dependent project's typecheck consume
the referenced project's *build output* (dist/*.d.ts), not its sources. The
shared package's dist is gitignored, is not rebuilt by any script before
`pnpm -r typecheck` or `pnpm --filter @backspace/server typecheck`, and the
referenced-project-stale failure mode surfaces as TS6305 in the server, or
(when the dist is present but older) as "no exported member" for types
that were added after the last shared build. The #16 merge (which added
DmCallUndeliverableFailure/Reason and the dm_call_undeliverable event
variant) landed the types in src but the local dist was never refreshed,
so server's typecheck started failing against the stale .d.ts.
packages/web/tsconfig.json already resolved @backspace/shared via
`moduleResolution: "bundler"` + the package.json `exports` field, which
points directly at ./src/types.ts. That path has no build-ordering
dependency, never goes stale, and already typecheck-passes cleanly.
Fix: remove the server-side project reference so server matches web's
bundler-style resolution. Server still emits its own dist on `tsc` (its
rootDir confines emission to its own src/); the runtime already reads
TS directly via tsx, so nothing in the dev or prod run path changes.
The Dockerfile/root build scripts that build shared explicitly are also
unaffected.
Verified: `pnpm -r typecheck` passes for shared and server; standalone
`pnpm exec tsc --noEmit` in packages/web passes; `pnpm build` completes
all three packages.
Fixes#24 (pre-existing typecheck failure on main).
packages/web/src/api/client.ts declared a local FederationPeer that had
drifted from @backspace/shared: it loosened `status` to `string` (losing
the exhaustive 7-value union) and widened `consecutiveFailures` and
`lastSyncedAt` to `number | null`. The latter two are spurious — the
server never returns null for either — and `status: string` defeated
the compiler's ability to flag a missed case when `rejected`,
`awaiting_approval`, or `needs_attention` were added over the course
of the auto-peering / approval-queue / outbox-auth-failure-recovery
work.
Replace the local interface with a re-export of the shared type. All
three status switches in FederationPanel.tsx (peerStatusColor,
peerStatusDotColor, peerStatusLabel) and the StatusFilter union were
already exhaustive over the 7 values, so no behaviour change is
needed — the re-export just pins the compile-time contract.
web tsc --noEmit is clean after the swap.
Follow-up #23 from S2S DM unification backlog.
The column was `integer DEFAULT 0` (nullable) since the initial schema.
Counters should not be nullable — the semantics are a count, not an
optional measurement. `consecutive_auth_failures` (added later) was
correctly declared NOT NULL; tightening `consecutive_failures` to match
removes the drift and eliminates the "|null" burden everywhere the value
is read.
SQLite does not support in-place ALTER … SET NOT NULL, so drizzle-kit
cannot auto-generate this. The manual migration uses the standard
SQLite recreate pattern (new table + INSERT SELECT + DROP + RENAME +
recreate index) under `PRAGMA defer_foreign_keys = ON` so the existing
federation_outbox → federation_peers FK survives the swap. The COPY
step coalesces any hypothetical NULL to 0 defensively; live probes on
both test instances (nova, orbit) showed zero NULL rows so no
actual backfill is required.
Verified by applying the full migration chain against a copy of the VM's
live DB: column ends as `notnull=1 dflt=0`, the peer row is preserved,
the unique index on origin is recreated, NULL inserts are rejected, and
`PRAGMA foreign_key_check` reports no violations.
Server `SanitizedPeer.consecutiveFailures` tightened to `number` to
match the new drizzle inference and the shared `FederationPeer` shape.
Follow-up #22 from S2S DM unification backlog.
HEALTH_CHECK_INTERVAL_MS was 1 h, but ROTATION_GRACE_PERIOD_MS is 15 min.
Phase skew between two peers' health-check ticks could stretch rotation
finalization desync up to ~1 h, during which signatures from the already-
finalized side verify against the other side's primary-only secret (grace
has expired; verifyPeerSignature stops trying the pending secret). With
AUTH_FAILURE_THRESHOLD = 5 and the existing backoff schedule, this
occasionally tripped legitimate rotations into needs_attention.
Setting the interval to 15 min (= ROTATION_GRACE_PERIOD_MS) guarantees a
finalization tick fires within one grace window on each side, so the
cross-verification window where one peer signs with NEW while the other
still treats NEW as pending cannot outlast the grace period.
Per-tick cost is negligible for the worker's steady state: the only
network fetches are per-active-peer /peer/rotate calls when the 90-day
rotation interval hits (rare) and per-unreachable-peer /instance/info
health pings (bounded by outage count). Going lower than 15 min would
reduce the residual desync but increase tick overhead with diminishing
returns; 15 min is the grace-period-aligned value that the original spec
("runs hourly") deviated from without justification.
Follow-up #20 from S2S DM unification backlog; reduces #19 false-positive
rate (outbox auth-failure transition) on legitimate rotations.
Replace the federation outbox worker's 401/403 wipe-and-rehandshake loop
with bounded retry (AUTH_FAILURE_THRESHOLD=5, ~21.5 min backoff window) and
a new `needs_attention` peer state. Surfaces persistent HMAC desync to
admins via a first-class 'Reset peering' action instead of the prior
silent loop.
Closes backlog item #19. Security invariants verified on live infra
(Pi+VM):
- hmac_secret is NEVER wiped in response to a network-observed 401/403
(Task 5 removes the wipe; Task 7 extends the /peer/accept idempotent-
200-no-update safeguard to cover needs_attention peers).
- Auth failures increment only consecutive_auth_failures, never the
network counter consecutive_failures (Task 5 splits
handleOutboxDeliveryFailure → applyOutboxEntryBackoff).
- Transition occurs at exactly 5 consecutive 401/403 responses; below
threshold, entries get backoff but state is preserved; above, peer
flips to needs_attention, affected users get federation_peer_rejected
WS with 'Federation trust broken — admin must reset peering'.
- /peer/accept safeguard confirmed against attacker curl probe on the
live Pi instance while in needs_attention — forged-secret request
returned 200-no-update, local hmac_secret unchanged.
- Legitimate rotation (Scenario B) does not false-positive: both sides
capture pending secret, auth_failures stays at 0, DM delivers cleanly
during grace period.
Four follow-up backlog items discovered during the work:
#20 health-check cadence tightening (15-min grace vs 1-hour tick)
#21 /peer/initiate 202 handling
#22 consecutive_failures nullability normalization
#23 unify client-side FederationPeer with shared type
Spec: internal notes
Plan: internal notes
Task 11 due-diligence audit for #19. Checked all signed-fetch sites in
packages/server/src for 4xx-branch mutations of federationPeers.hmacSecret
or federationPeers.status:
- sendCallRelay (federationOutbox.ts): on 4xx returns post_failed; on
5xx/network returns peer_transient_failure. No peer-state mutation.
- sendTypingRelay (federationOutbox.ts): delegates to sendCallRelay with
peeringTimeoutMs:0 (fire-and-forget). No peer-state mutation.
- cleanupExpiredApprovalRequests (storageJanitor.ts): sends denial,
only deletes peerApprovalRequests row on success. No federationPeers
mutation.
- DELETE /identity (users.ts): per-origin cleanup; only deletes local
userFederationRegistry on success, never touches federationPeers.
- denyApprovalRequest (federation.ts): requires 2xx from remote before
inserting/updating a rejected peer row. Admin-driven, not wipe.
- POST /peers/:id/rotate (federation.ts): mutates pendingHmacSecret only
on 2xx; returns 502 on 4xx without state change.
- Auto-rotation in federationWorker.ts: same 2xx-gated pattern as manual
rotate.
- Unreachable-recovery health check: only promotes to active on 2xx.
- ensurePeered/performHandshake (federationPeering.ts): on 403 with
PEERING_REQUIRES_APPROVAL sets status='rejected' (explicit, not a
HMAC-mismatch wipe); on other 4xx/5xx only deletes the row if it was
a freshly created placeholder (existingPeerId falsy). Pre-existing
peers are untouched.
Only federationWorker.ts:269 mutates HMAC-related state in response to
401/403, and that path was rewritten in Task 5 to use
evaluateAuthFailure and transition to needs_attention. No additional
handlers require the bounded-retry refactor.
- peerStatusLabel/Color/DotColor gain a 'needs_attention' case (rose).
- StatusFilter row gains 'Needs Attention' toggle.
- PeerRow hides Rotate/Revoke and shows 'Reset Peering' when status is
needs_attention, plus an Auth Failures stat.
- Parent panel routes 'reset' through a ConfirmDialog (danger variant)
that spells out the destructive nature and the out-of-band re-peer step.
- Client FederationPeer interface gains consecutiveAuthFailures (Task 2
extended the shared type but the web client's local mirror was stale).
Codifies the manual 'delete both sides, re-peer' workaround as a
first-class admin action.
Admin-only endpoint for recovering from needs_attention. Deletes the
local peer row; FK cascade removes queued outbox entries. Gated to
peers in needs_attention to prevent accidental resets of healthy
peerings (use /peers/:id for revoke on active peers).
Also extends the Task 8.5 test mock of '../db/index.js' to re-export
`schema`. federation.ts imports `schema` from the re-export alongside
`getDb`; the previous mock only exposed `getDb`, causing the route
handler to blow up with 500s before reaching any assertion. This is
a scaffolding fix — no test assertions were changed.
Four cases from the spec's testing strategy: 404 on missing peer, 400 on
wrong status, 403 for non-admin, and successful delete including FK
cascade of queued outbox entries. Introduces a minimal Fastify-inject
harness for route testing — previously the codebase had only pure-function
unit tests under utils/.
Tests intentionally FAIL at this commit — Task 8 will add the handler and
close the loop.
Unauthenticated /peer/accept must not overwrite hmac_secret for peers in
needs_attention, same as active. needs_attention means 'auth trust broke
and we don't know why' — letting an unauthenticated request flip it back
would reintroduce a path for silent HMAC rotation via the outbox-401 loop
the rest of #19 closes. Legitimate recovery is the admin 'Reset peering'
action (next task).
Pairs with the new 401/403 handler — a 2xx relay confirms HMAC trust is
healthy so the counter should clear. Mirrors the existing
consecutive_failures reset for network-layer health.
Code review of the previous commit found that the backoff branch of the
new 401/403 handler delegated to handleOutboxDeliveryFailure, which
double-dips by also incrementing consecutive_failures (the network-layer
counter that drives the 'unreachable' transition at threshold 10). Per the
design spec §State Machine Changes → Reset logic, auth failures must
increment consecutive_auth_failures ONLY.
Split handleOutboxDeliveryFailure into:
- applyOutboxEntryBackoff: just the per-entry backoff update (safe to call
from the auth-failure path)
- handleOutboxDeliveryFailure: entry backoff + peer's consecutive_failures
bump (network-error path only)
Also adds a console.warn to the backoff branch so operators can diagnose
clock-skew and rotation-grace incidents before the peer hits the terminal
threshold.
Part of backlog #19.
The previous handler (commit ce33ccf + its 403 extension) wiped hmac_secret
and reset peer status to 'pending' on any 401/403 from an active peer. This
collapsed three distinct failure modes — transient clock skew, legitimate
split-brain, active MITM attempt — into "silently establish new trust
immediately." The remote's /peer/accept idempotent-200-no-update safeguard
then prevented the re-handshake from actually working, producing a 1-req/sec
loop observed during backlog #16 verification.
New behavior: increment consecutive_auth_failures, apply backoff to outbox
entries. At AUTH_FAILURE_THRESHOLD (5) transition to needs_attention,
preserve hmac_secret, surface delivery-impossible to affected users,
notify admins. Secret is NEVER wiped in response to a network-observed
401/403.
Part of backlog #19.
Pure function deciding whether the next 401/403 from an active peer
triggers backoff or a transition to needs_attention. Threshold = 5,
corresponding to ~21.5 min of the existing BACKOFF_SCHEDULE_MS.
Adds 'rejected', 'awaiting_approval', and 'needs_attention' to the status
union, plus the consecutiveAuthFailures / autoRotateIntervalDays /
secretRotatedAt / rotationInProgress fields the UI already reads.
Tracks HMAC-failure count separately from consecutive_failures (network
errors). Auth failures and network failures have different resolution
paths; mixing them would let a single successful retry after a network
blip mask real secret desync.
Part of backlog #19 — outbox auth-failure recovery.
Note: drizzle-kit generated the 0003 SQL with an unexpected CREATE TABLE
for peer_approval_requests because the 0002 migration was authored
manually without a corresponding 0002_snapshot.json (see 14041e9). The
generated SQL has been trimmed to the single intended ALTER TABLE. The
regenerated 0003_snapshot.json correctly reflects the full current
schema, so future migrations will diff cleanly.
Closes backlog #16. Implements sendCallRelay/sendTypingRelay auto-peering
and caller-facing dm_call_undeliverable failure surface.
See internal notes
and internal notes
for the full design + implementation plan.
sendFederatedCallStart now collects per-targeted-peer results and emits
a single dm_call_undeliverable event to the caller when any targeted
peer relay fails. Destroys the local ring room when no plausible
recipient remains (no targeted success + no connected local ringee).
LiveKit pre-flight also emits via this path with reason
'livekit_unavailable' instead of a silent console.warn, closing the
60s hang for unconfigured instances.
Guards against phantom toasts when the caller cancels mid-race by
checking getRoom() before emitting.