Skip to content

feat(relay): show honest wake-up proof states - #98

Merged
psimaker merged 2 commits into
mainfrom
feat/issue-91-relay-observability
Jul 12, 2026
Merged

feat(relay): show honest wake-up proof states#98
psimaker merged 2 commits into
mainfrom
feat/issue-91-relay-observability

Conversation

@psimaker

@psimaker psimaker commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Query the verified per-homeserver Relay observation only in waiting and diagnostics views, with bounded polling and independent partial results. Keep Relay observation, local wake-up receipt, and sync progress as separate evidence without changing existing-user setup or sync mappings. (#91)

What & why

Component(s)

  • go (bridge / Syncthing)
  • ios (app / widget)
  • notify (relay)
  • docs / CI

Testing

  • cd go && make patch && go test -tags noassets ./bridge
  • cd notify && go test ./...
  • iOS build / xcodebuild test
  • Not applicable

Summary

  • Adds honest Relay wake-up proof states, distinguishing server observation, local wake-up receipt, background sync start, and observed sync progress.
  • Adds bounded, independent per-homeserver polling in waiting and diagnostics views, with partial results and clear failure/rate-limit handling.
  • Updates Relay and UI diagnostics to show which synchronization step is still pending.
  • Documents the Relay observation contract, proof hierarchy, and stored “last-signal” timestamp, including deletion and privacy limitations.
  • Adds localized user-facing status and diagnostic messaging.
  • Records background-sync lifecycle evidence and adds comprehensive tests for entitlement gating, decoding, polling, partial failures, presentation states, and proof separation.

Query the verified per-homeserver Relay observation only in waiting and
diagnostics views, with bounded polling and independent partial results. Keep
Relay observation, local wake-up receipt, and sync progress as separate evidence
without changing existing-user setup or sync mappings. (#91)
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@psimaker, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bb097b05-e2ed-4c33-b01c-21f998e25f87

📥 Commits

Reviewing files that changed from the base of the PR and between b1878a2 and 70905ea.

📒 Files selected for processing (3)
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Services/SubscriptionManager.swift
  • ios/VaultSyncTests/RelayStatusTests.swift
📝 Walkthrough

Walkthrough

Cloud Relay now exposes per-device signal observation, bounded polling, and layered sync-proof state. The iOS app records background-sync milestones, presents more precise relay statuses, updates diagnostics, and documents the protocol and privacy behavior.

Changes

Relay observation and proof hierarchy

Layer / File(s) Summary
Relay observation contract
CHANGELOG.md, PRIVACY.md, docs/architecture.md, docs/decisions/..., docs/relay-spec.md
Documents signal timestamps, /api/v1/status, verification limits, polling behavior, and separate evidence levels for reachability, delivery, and synchronization.
Status service and proof primitives
ios/VaultSync/App/AppDelegate.swift, ios/VaultSync/Services/RelayService.swift, ios/VaultSync/Services/RelayStatus.swift
Adds status requests and decoding, typed failures, bounded polling, proof snapshots, and persisted sync milestone timestamps.
Subscription observation orchestration
ios/VaultSync/Services/SubscriptionManager.swift
Tracks per-device observations and failures, coordinates polling, derives user statuses, and assembles proof snapshots.
Relay status UI and localization
ios/VaultSync/Views/RelayDiagnosticsView.swift, ios/VaultSync/Views/RelayHomeView.swift, ios/VaultSync/*lproj/Localizable.strings
Displays observation and sync details, updates waiting/active states, and adds localized status messaging.
Behavior validation
ios/VaultSyncTests/RelayStatusTests.swift, ios/VaultSyncTests/TestSupport.swift
Tests entitlement gating, decoding, retries, polling stops, presentation states, proof non-escalation, copy, and state cleanup.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Poem

Signals arrive, one proof at a time,
Polling keeps rhythm, bounded and prime.
A wake may whisper, sync may begin,
Each layer records what it has seen.
Relay lights glow, honest and bright.

🚥 Pre-merge checks | ✅ 7
✅ Passed checks (7 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title uses a conventional-commit feat scope and clearly summarizes the main Relay wake-up proof-state change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No Private Note Leakage ✅ Passed No note/vault-path/bookmark/secret values are logged or stored; status checks send only device_id + signed_transaction, with generic diagnostics and no APNs token in the new path.
Bounded Ios Background Work ✅ Passed PASS: background sync stays bounded (25s max), cancellation-aware, and cleanup/expiration handlers exist on BG tasks; new status errors are generic and don’t log vault contents.
Bridge Contract Compatibility ✅ Passed No Go bridge or SyncBridgeService surface changed; gomobile exports and empty-string wrappers remain intact, and the new Relay status code stays pure Swift with matching tests.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-91-relay-observability

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ios/VaultSync/Services/RelayStatus.swift (1)

15-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Clarify the intentionally-narrow scope of requiresPollingStop/wasRateLimited.

Both computed properties filter by requestedDeviceIDs, which is deliberately left empty when the global entitlement check fails (line 62, confirmed by the cited test asserting outcome.requestedDeviceIDs.isEmpty for that branch). That means a .verificationRequired failure set in the entitlement-invalid branch never trips requiresPollingStop — polling only stops there because the current caller (pollRelayObservationStatus's shouldContinue) independently re-checks entitlement every iteration. That's correct today, but it's an easy trap for a future caller (e.g. a differently-scoped polling context) that relies on requiresPollingStop alone and doesn't duplicate the entitlement check.

A short doc comment noting "only reflects failures for devices actually queried this pass; global entitlement/subscription lapses must be checked by the caller's shouldContinue" would prevent a future regression in this safety-critical proof-hierarchy code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/VaultSync/Services/RelayStatus.swift` around lines 15 - 29, Add a concise
documentation comment to RelayStatusCheckOutcome.requiresPollingStop and
wasRateLimited explaining that they only consider failures for devices queried
in the current pass, while global entitlement or subscription lapses must be
checked by the caller’s shouldContinue logic. Preserve the existing
requestedDeviceIDs filtering and behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ios/VaultSync/Services/RelayStatus.swift`:
- Around line 15-29: Add a concise documentation comment to
RelayStatusCheckOutcome.requiresPollingStop and wasRateLimited explaining that
they only consider failures for devices queried in the current pass, while
global entitlement or subscription lapses must be checked by the caller’s
shouldContinue logic. Preserve the existing requestedDeviceIDs filtering and
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bb27edb9-7d7d-4221-ada3-7b9fb706b9c2

📥 Commits

Reviewing files that changed from the base of the PR and between 605fd28 and b1878a2.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • PRIVACY.md
  • docs/architecture.md
  • docs/decisions/019-relay-proof-hierarchy.md
  • docs/relay-spec.md
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Services/SubscriptionManager.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • ios/VaultSync/de.lproj/Localizable.strings
  • ios/VaultSync/en.lproj/Localizable.strings
  • ios/VaultSync/es.lproj/Localizable.strings
  • ios/VaultSync/zh-Hans.lproj/Localizable.strings
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSyncTests/TestSupport.swift
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: CodeRabbit
🧰 Additional context used
📓 Path-based instructions (10)
ios/**/*.{swift,strings,stringsdict}

📄 CodeRabbit inference engine (README.md)

Support localization in English, German, Spanish, and Simplified Chinese

Files:

  • ios/VaultSync/zh-Hans.lproj/Localizable.strings
  • ios/VaultSync/de.lproj/Localizable.strings
  • ios/VaultSync/en.lproj/Localizable.strings
  • ios/VaultSync/es.lproj/Localizable.strings
  • ios/VaultSyncTests/TestSupport.swift
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • ios/VaultSync/Services/SubscriptionManager.swift
**

⚙️ CodeRabbit configuration file

**:

VaultSync

VaultSync

Self-hosted Obsidian vault sync for iPhone and iPad.

Your notes sync peer-to-peer over Syncthing, straight into Obsidian's iOS sandbox — no note cloud, no account, no tracking.

Download on the App Store



Stars
License: MPL-2.0
iOS 18+
CI

VaultSync welcome screen VaultSync home screen

🔭 Why VaultSync

  • Peer-to-peer & private — syncs directly between your own devices over Syncthing. No note cloud, no account, no tracking.
  • Lands in Obsidian — files sync into Obsidian's iOS sandbox, where the app already looks for them.
  • Pair by QR, resolve conflicts — connect your server in seconds; settle Markdown conflicts with side-by-side diffs.
  • Server changes wake your iPhone — optional Cloud Relay nudges the app the moment your server updates, so incoming notes land eve...

Files:

  • ios/VaultSync/zh-Hans.lproj/Localizable.strings
  • docs/decisions/019-relay-proof-hierarchy.md
  • ios/VaultSync/de.lproj/Localizable.strings
  • ios/VaultSync/en.lproj/Localizable.strings
  • ios/VaultSync/es.lproj/Localizable.strings
  • ios/VaultSyncTests/TestSupport.swift
  • docs/architecture.md
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • CHANGELOG.md
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • docs/relay-spec.md
  • ios/VaultSync/Services/SubscriptionManager.swift
  • PRIVACY.md
**/*

⚙️ CodeRabbit configuration file

**/*: VaultSync syncs private Obsidian notes through Syncthing. Treat data loss,
privacy leaks, security regressions, and broken sync behavior as high priority.
Do not nitpick formatting unless it affects maintainability, correctness, or public API clarity.
Flag any accidental logging, telemetry, crash reporting, or network transfer of note contents,
vault paths, filenames with private context, API keys, APNs tokens, relay keys, or security-scoped bookmark data.

Files:

  • ios/VaultSync/zh-Hans.lproj/Localizable.strings
  • docs/decisions/019-relay-proof-hierarchy.md
  • ios/VaultSync/de.lproj/Localizable.strings
  • ios/VaultSync/en.lproj/Localizable.strings
  • ios/VaultSync/es.lproj/Localizable.strings
  • ios/VaultSyncTests/TestSupport.swift
  • docs/architecture.md
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • CHANGELOG.md
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • docs/relay-spec.md
  • ios/VaultSync/Services/SubscriptionManager.swift
  • PRIVACY.md
docs/decisions/**/*

📄 CodeRabbit inference engine (docs/decisions/019-relay-proof-hierarchy.md)

docs/decisions/**/*: Model StoreKit verification, verified provisioning, backend reachability, per-homeserver v1 observation, local wake-up receipt, background-sync start, and observed sync progress as independent evidence.
A valid debounced v1 signal may update Relay observation, but must not set APNs delivery or sync success.
Normal UI should use plain-language waiting states, while Diagnostics should name the technical boundary and keep multi-homeserver results separate.
Do not treat /health, provisioning, or Relay observation as active delivery, because each skips a stronger downstream leg.
Do not treat silent-push receipt as completed sync; later sync progress must remain independently unproven until observed.

Files:

  • docs/decisions/019-relay-proof-hierarchy.md
docs/**

⚙️ CodeRabbit configuration file

docs/**: # Architecture

VaultSync embeds Syncthing's Go reference implementation as an iOS library via gomobile — no reimplementation of the protocol in Swift, and guaranteed wire compatibility.

┌─────────────────────────────────┐
│         SwiftUI Frontend        │   iOS-native UI, Swift 6
├─────────────────────────────────┤
│       Swift ↔ Go Bridge         │   thin API via gomobile
│                                 │   → exported as .xcframework
├─────────────────────────────────┤
│        syncthing/lib (Go)       │   protocol, discovery, sync
└─────────────────────────────────┘
              ↕ filesystem
┌─────────────────────────────────┐
│    Obsidian Vault (direct)      │   Obsidian's iOS sandbox
└─────────────────────────────────┘

🔄 Sync strategy

  • Foreground — Syncthing runs unrestricted: immediate, continuous sync.
  • BackgroundBGAppRefreshTask (requested ~15 min out; iOS decides the actual timing) + BGProcessingTask (overnight catch-up: multi-minute budget while charging with network) + BGContinuedProcessingTask (iOS 26+, longer runtime for user-initiated tasks). A ~30s grace window after backgrounding lets in-flight work finish.
  • Push (Cloud Relay) — optional. Near-realtime server → iPhone wake-ups via APNs silent push. See relay-spec.md.

VaultSync is intentionally asymmetric:

Direction Path
Server → iPhone vaultsync-notify spots outgoing changes → Cloud Relay silent push → VaultSync wakes and pulls.
iPhone → Server iOS doesn't guarantee timely background execution for local edits. The reliable path is to open VaultSync and let embedded Syncthing run in the foreground — a Shortcuts automation can do that automatically whenever you leave Obsidian.

Cloud Relay is a server → iPhone acceleration path, not a guarantee of symmetric real-time background sync.

Connection paths & iOS network privacy

How peers are reached, fastest f...

Files:

  • docs/decisions/019-relay-proof-hierarchy.md
  • docs/architecture.md
  • docs/relay-spec.md
docs/decisions/**

⚙️ CodeRabbit configuration file

docs/decisions/**: # 001 — Two Syncthing folders never overlap on disk (three enforcement layers)

Context: A server sharing more than one vault could hand the second share the same local folder as the first (1.6.0–1.7.0) or a subfolder inside an existing vault (1.7.1). Overlapping folders sync each other's content as their own — deleting the stray copy on any peer would have deleted the inner vault everywhere (#45).

Decision: The no-overlap invariant (equal, nested, or containing paths) is enforced in three independent layers, each with its own tests: the Go hard floor (AcceptPendingFolder/AddFolder reject with folderPathOverlapError), the Swift mapping (VaultManager.resolveSharePath returns nil rather than an overlapping path), and the launch shield (PathCollisionGuard pauses already-overlapping folders exactly once).

Why: The single-layer version failed twice — both #45 bugs lived in the Swift mapping. The Go floor backstops future mapping bugs, the Swift layer turns a hard engine error into user guidance, and only the shield catches damage that predates the fix.

Rejected alternative: Enforcing only in the Swift mapping — the proven failure mode; a bug there would silently re-open the hole with no backstop.

Links: #45, PR #47 (same-folder merge, 1.7.1), PR #51 (nesting, 1.7.2).

docs/decisions/**: # 002 — Recovery from data damage is never automatic

Context: When VaultSync detects existing damage (two vaults merged into one folder, or one nested inside another, #45), an automatic repair would have to move, rename, delete, or re-accept user data whose true state only the user knows — after a merge, the app cannot tell which files belong to which vault.

Decision: Recovery is always: pause the affected folders (exactly once — a folder the user deliberately resumes is never re-paused), explain the problem in a critical issue with concrete recovery steps, and let the user act.

Why: Sync propagates every local act...

Files:

  • docs/decisions/019-relay-proof-hierarchy.md
**/*.md

⚙️ CodeRabbit configuration file

**/*.md: Review public documentation for technical accuracy, privacy/security claims, App Store-facing wording,
setup correctness, and consistency with the free app plus optional Cloud Relay subscription model.

Files:

  • docs/decisions/019-relay-proof-hierarchy.md
  • docs/architecture.md
  • CHANGELOG.md
  • docs/relay-spec.md
  • PRIVACY.md
**/*.swift

📄 CodeRabbit inference engine (Custom checks)

For Swift background execution changes, pass if work is bounded, cancellation-aware, handles expiration callbacks, and records errors without leaking private vault data. Fail only when background work can continue unbounded, miss cleanup, or violate iOS background execution constraints.

Files:

  • ios/VaultSyncTests/TestSupport.swift
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • ios/VaultSync/Services/SubscriptionManager.swift
ios/**/*.swift

📄 CodeRabbit inference engine (README.md)

ios/**/*.swift: Use Swift 6 and SwiftUI for iOS app development
Implement VoiceOver and Dynamic Type accessibility support throughout the app
Use BGAppRefreshTask and BGContinuedProcessingTask (iOS 26+ when available) for background sync operations
Use APNs silent push notifications via Cloud Relay for server-to-iPhone wake-ups
Implement side-by-side diff resolution for Markdown file conflicts
Provide an activity timeline and diagnostics interface showing exactly what synced and when
Implement QR code pairing for Syncthing Device ID connection setup
Detect and list available Obsidian vaults automatically upon connection

Files:

  • ios/VaultSyncTests/TestSupport.swift
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • ios/VaultSync/Services/SubscriptionManager.swift

⚙️ CodeRabbit configuration file

ios/**/*.swift: Focus on Swift 6 strict concurrency, Sendable/MainActor correctness, Task cancellation,
retain cycles, memory pressure, SwiftUI observation state, StoreKit/APNs flows, and iOS background execution limits.
Pay special attention to BGAppRefreshTask and BGContinuedProcessingTask behavior, expiration handling,
bounded work, and cleanup when the app is suspended or terminated.

Files:

  • ios/VaultSyncTests/TestSupport.swift
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • ios/VaultSync/Services/SubscriptionManager.swift
ios/**/*.{swift,pbxproj}

📄 CodeRabbit inference engine (README.md)

Target iOS / iPadOS 18 or later as the minimum deployment target

Files:

  • ios/VaultSyncTests/TestSupport.swift
  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • ios/VaultSyncTests/RelayStatusTests.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • ios/VaultSync/Services/SubscriptionManager.swift
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
Repo: psimaker/vaultsync

Timestamp: 2026-07-12T08:16:59.280Z
Learning: Use `BGAppRefreshTask`, `BGProcessingTask`, and, on iOS 26+, `BGContinuedProcessingTask` according to their intended background-sync roles, while allowing roughly 30 seconds for in-flight work after backgrounding.
Learnt from: CR
Repo: psimaker/vaultsync

Timestamp: 2026-07-12T08:16:59.280Z
Learning: Treat Cloud Relay as an optional server-to-iPhone acceleration path, not as guaranteed symmetric real-time background synchronization.
Learnt from: CR
Repo: psimaker/vaultsync

Timestamp: 2026-07-12T08:16:59.280Z
Learning: Keep entitlement, relay provisioning, relay reachability, relay observation, push receipt, background sync start, and local sync progress as separate proofs; none automatically implies the next.
Learnt from: CR
Repo: psimaker/vaultsync

Timestamp: 2026-07-12T08:16:59.280Z
Learning: Use direct LAN, direct WAN, and Syncthing relay connection paths in that priority order, respecting iOS Local Network permission and multicast entitlement requirements.
📚 Learning: 2026-06-10T18:47:10.724Z
Learnt from: psimaker
Repo: psimaker/vaultsync PR: 38
File: ios/VaultSync/Views/ContentView.swift:605-611
Timestamp: 2026-06-10T18:47:10.724Z
Learning: In the SwiftUI codebase under ios/VaultSync, do not flag missing localization for SwiftUI string literals used as Text("…") or DisclosureGroup("…") titles/labels. In SwiftUI, these string literals are treated as LocalizedStringKey and resolve via the app’s Localizable.strings automatically—so they only need attention if the corresponding key is actually missing. Only require an explicit localization helper (e.g., L10n.tr(…)) when the string is not being passed through SwiftUI’s LocalizedStringKey path (e.g., plain String values provided to non-SwiftUI APIs).

Applied to files:

  • ios/VaultSync/App/AppDelegate.swift
  • ios/VaultSync/Services/RelayStatus.swift
  • ios/VaultSync/Views/RelayDiagnosticsView.swift
  • ios/VaultSync/Services/RelayService.swift
  • ios/VaultSync/Views/RelayHomeView.swift
  • ios/VaultSync/Services/SubscriptionManager.swift
🪛 LanguageTool
docs/decisions/019-relay-proof-hierarchy.md

[style] ~4-~4: The words ‘observation’ and ‘observed’ are quite similar. Consider replacing ‘observed’ with a different word.
Context: ...-up receipt, background-sync start, and observed sync progress as independent evidence. ...

(VERB_NOUN_SENT_LEVEL_REP)

docs/relay-spec.md

[style] ~310-~310: The words ‘observation’ and ‘observed’ are quite similar. Consider replacing ‘observed’ with a different word.
Context: ...ush receipt, background-sync start, and observed local sync progress as separate evidenc...

(VERB_NOUN_SENT_LEVEL_REP)

🔇 Additional comments (21)
ios/VaultSyncTests/RelayStatusTests.swift (1)

35-53: LGTM!

Also applies to: 55-77, 79-93, 95-106, 108-141, 143-171, 173-179, 181-201, 203-217, 219-232, 234-252, 254-280, 282-299, 301-322

ios/VaultSyncTests/TestSupport.swift (1)

32-33: LGTM!

CHANGELOG.md (1)

9-20: LGTM!

PRIVACY.md (1)

3-3: LGTM!

Also applies to: 18-30, 47-47

docs/architecture.md (1)

35-45: LGTM!

docs/decisions/019-relay-proof-hierarchy.md (1)

1-11: LGTM!

docs/relay-spec.md (1)

3-3: LGTM! The new /status wire contract (request fields, response shapes, verification rules) matches the iOS client's makeStatusRequest/decodeStatusResponse implementation, and the polling cadence description (immediate + 15/30/60/120s, 5-attempt cap) matches RelayStatusPollingPolicy.waitingView.

Also applies to: 72-84, 154-159, 174-210, 222-225, 255-255, 266-266, 301-313

ios/VaultSync/App/AppDelegate.swift (1)

62-81: LGTM! markBackgroundSyncStarted is recorded unconditionally as an attempt marker, and markSyncProgressObserved is only set on .synced, keeping the proof ladder from overclaiming — consistent with decision 019.

ios/VaultSync/Services/RelayService.swift (1)

136-136: LGTM! Dropping the raw response body from logger.error while still preserving it in on-device recordLastError diagnostics is a sensible privacy-hardening tweak, and the new fetchStatus/makeStatusRequest/decodeStatusResponse trio correctly gates on a verified entitlement, matches the documented /status wire contract, and validates triggerObserved == (lastObservedAt != nil) before returning.

Also applies to: 183-280, 426-434

ios/VaultSync/Services/RelayStatus.swift (1)

1-14: LGTM! RelayStatusChecking/RelayStatusPolling are correctly @MainActor-isolated, the bounded-poll loop's retryDelays[attempts - 1] indexing is safe for all attempt counts, Task.sleep cancellation is handled via the do/catch break, and RelayStatusPresentation.status's priority ordering (local wake-up → failure → observation staleness) matches the documented proof hierarchy.

Also applies to: 30-276

ios/VaultSync/Services/SubscriptionManager.swift (4)

45-47: 🗄️ Data Integrity & Integration | ⚡ Quick win

New observation/failure caches never clear on deprovision — stale proof survives a resubscribe.

relayServerObservations/relayStatusFailures are written by checkRelayObservationStatus but nothing resets them. deprovisionRelay() (line 748) already resets relayProvisionStatuses and clears the "connected-celebrated" flag on every transition to inactive, but leaves these two new dictionaries untouched. After a lapse + resubscribe, RelayDiagnosticsView's "Server Signal Observation" section can briefly show a pre-lapse lastTriggerObservedAt/checkedAt as if it were current — exactly the kind of stale-evidence-presented-as-fresh problem this PR is otherwise careful to avoid.

🧹 Proposed fix
     private func deprovisionRelay() async {
         let deviceIDs = loadStoredDeviceIDs()
         ensureProvisionStateEntries(for: deviceIDs)
         for deviceID in relayProvisionStatuses.keys {
             relayProvisionStatuses[deviceID] = .notAttempted
         }
+        // Clear relay-observation evidence too, so a later resubscribe never
+        // presents a pre-lapse timestamp as current.
+        relayServerObservations = [:]
+        relayStatusFailures = [:]
         RelayProvisionStatusStore.save(

61-61: LGTM!

Also applies to: 77-91


472-529: 🩺 Stability & Availability | ⚡ Quick win

Concurrent checkRelayObservationStatus calls can overwrite each other's fresher results.

checkRelayObservationStatus snapshots relayServerObservations/relayStatusFailures at call start, awaits the network round-trip, then assigns the whole dictionaries back from that snapshot + its own outcome (lines 492-493). relayStatusCheckInFlight is set/cleared but never checked, so it's cosmetic, not a guard.

This is reachable in practice, not just theoretically: pollRelayObservationStatus (called from both RelayHomeView.task and RelayDiagnosticsView.task) retries in a loop, and RelayDiagnosticsView also exposes a "Check Relay Status" button that calls checkRelayObservationStatus directly. Since a NavigationLink push doesn't cancel the source view's .task, and RelayHomeView even adds a dedicated link into RelayDiagnosticsView specifically while its own poll loop is active (.relayObservedWaitingForWakeUp, lines 180-193 in RelayHomeView.swift), two overlapping calls against different device-ID subsets are a normal user flow, not an edge case. Whichever call's network round-trip finishes last wins and can clobber a device's freshly-observed data with the stale snapshot the losing call started with. It self-heals on the next successful check, but it works against the exact goal of this PR (accurate, non-stale proof state).

🔒 Proposed reentrancy guard
     `@discardableResult`
     func checkRelayObservationStatus(
         homeserverDeviceIDs: [String]
     ) async -> RelayStatusCheckOutcome {
+        guard !relayStatusCheckInFlight else {
+            // A concurrent check is already in flight (Home + Diagnostics can both
+            // poll at once) — don't race it with a second snapshot/overwrite.
+            return RelayStatusCheckOutcome(
+                observations: relayServerObservations,
+                failures: relayStatusFailures,
+                requestedDeviceIDs: []
+            )
+        }
         relayStatusCheckInFlight = true
         defer { relayStatusCheckInFlight = false }
         let outcome = await RelayStatusChecking.run(

Note the polling loop can also produce a similar artifact on plain task cancellation: RelayStatusPolling.run's check() call isn't itself cancellation-aware mid-flight, so a check already in flight when the view disappears can still complete and write a spurious .temporarilyUnavailable failure. The guard above incidentally helps here too, since a fresh call after re-appearing won't race the tail of the cancelled one.


531-570: LGTM! Priority-based relayUserStatus aggregation and relayProofSnapshot's field set map cleanly onto the independent-evidence-layers model (StoreKit verification, provisioning, backend health, relay observation, silent-push receipt, background-sync start, sync progress kept separate, none implying the next).

ios/VaultSync/Views/RelayDiagnosticsView.swift (1)

16-31: LGTM! The keyed .task, the two new "Last background sync start"/"Last observed sync progress" rows, the per-device observationSection, and statusFailureText/runDiagnostics(checkObservationStatus:) are all consistent and correctly localized across en/de/es/zh-Hans. (See the companion note on SubscriptionManager.checkRelayObservationStatus for a cross-view concurrency concern that affects this screen's polling but originates there.)

Also applies to: 289-306, 334-381, 542-562

ios/VaultSync/Views/RelayHomeView.swift (2)

29-31: LGTM! relayUserStatus-driven state (celebration, active, and the new relayObservedWaitingForWakeUp diagnostics prompt) reads correctly and keeps the messaging honest about delivery vs. sync completion.

Also applies to: 43-50, 127-132, 142-145, 155-156, 169-193


234-277: 📐 Maintainability & Code Quality | 💤 Low value

Six near-identical branches — consider collapsing on badge style.

Every case except .wakeUpReceived repeats the same StatusBadge(...) + Text(...) pair, differing only in the StatusBadge style. A small helper keeps this from drifting as more RelayUserStatus cases get added.

♻️ Illustrative consolidation
 `@ViewBuilder`
 private var relayWaitingStatus: some View {
-    switch relayUserStatus {
-    case .checking:
-        StatusBadge(.starting, text: relayUserStatus.userFacingTitle)
-            .font(.headline)
-        Text(relayUserStatus.userFacingDetail)
-            .font(.subheadline)
-            .foregroundStyle(.secondary)
-    case .waitingForFirstSignal:
-        StatusBadge(.attention, text: relayUserStatus.userFacingTitle)
-        ...
-    ...
-    case .wakeUpReceived:
-        EmptyView()
-    }
+    if relayUserStatus == .wakeUpReceived {
+        EmptyView()
+    } else {
+        StatusBadge(badgeStyle(for: relayUserStatus), text: relayUserStatus.userFacingTitle)
+            .font(.headline)
+        Text(relayUserStatus.userFacingDetail)
+            .font(.subheadline)
+            .foregroundStyle(.secondary)
+    }
+}
+
+private func badgeStyle(for status: RelayUserStatus) -> StatusBadge.Style {
+    switch status {
+    case .checking, .relayObservedWithinGrace: return .starting
+    case .waitingForFirstSignal, .relayObservedWaitingForWakeUp, .statusUnavailable: return .attention
+    case .quietCanBeNormal: return .paused
+    case .wakeUpReceived: return .starting // unreachable, guarded above
+    }
 }
ios/VaultSync/de.lproj/Localizable.strings (1)

727-761: LGTM! New relay-observation strings are complete and consistent with the other locales.

ios/VaultSync/en.lproj/Localizable.strings (1)

727-761: LGTM! Base English strings for the new relay-observation/proof-hierarchy section are complete and match what's referenced from SubscriptionManager.swift, RelayDiagnosticsView.swift, and RelayHomeView.swift.

ios/VaultSync/es.lproj/Localizable.strings (1)

727-761: LGTM! Translations are complete and match the English key set 1:1, including format specifiers.

ios/VaultSync/zh-Hans.lproj/Localizable.strings (1)

727-761: LGTM! Translations are complete and match the English key set 1:1, including format specifiers.

@psimaker
psimaker merged commit 16bc2a4 into main Jul 12, 2026
15 checks passed
@psimaker
psimaker deleted the feat/issue-91-relay-observability branch July 12, 2026 09:13
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