Skip to main content

max / makenotwork

synckit: instrument the conflict layer and billing calls (audit Phase 3) Observability remediation from the 2026-07-02 /audit arbiter: - Conflict resolution now logs its data-loss decision points: every LWW resolution (which side was kept), the null-base field-merge fallback, and a clean change gated out by the committed HLC. Silent data loss was previously undiagnosable after the fact. (Clock-poisoning already warns, from Phase 1.) - subscription.rs: the six outbound billing/account methods now carry #[tracing::instrument]; the module previously had none. - validate_api_key is instrumented, skipping the api_key argument. Gate: 405 crate tests pass, cargo clippy --all-targets clean, GoingsOn cargo check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-03 01:36 UTC
Commit: cfd9de70c5b2208ee3ebf97e1bfc4a5b0a1dc08a
Parent: ff01045
3 files changed, +36 insertions, -5 deletions
@@ -343,6 +343,7 @@ fn requires_https(server_url: &str) -> bool {
343 343 /// Returns the app name on success, or an error if the key is invalid or the server
344 344 /// is unreachable. This is intended for setup UIs that need to verify a key before
345 345 /// saving it.
346 + #[tracing::instrument(skip(api_key))]
346 347 pub async fn validate_api_key(server_url: &str, api_key: &str) -> Result<String> {
347 348 let url = format!("{server_url}/api/v1/sync/validate-app");
348 349 let http = reqwest::Client::builder()
@@ -229,6 +229,7 @@ impl SyncKitClient {
229 229 /// Fetch the pricing-formula constants for this app. No JWT needed; the
230 230 /// app's API key is sent in the body. Safe to call before login so the
231 231 /// UI can show pricing on the marketing/onboarding view.
232 + #[tracing::instrument(skip(self))]
232 233 pub async fn get_app_pricing(&self) -> Result<AppPricing> {
233 234 let body = Bytes::from(serde_json::to_vec(&AppPricingRequest {
234 235 api_key: &self.config.api_key,
@@ -247,6 +248,7 @@ impl SyncKitClient {
247 248 /// Server-side price quote for a (cap, interval). Use this to confirm
248 249 /// the number you display matches what Stripe will charge; the result is
249 250 /// authoritative.
251 + #[tracing::instrument(skip(self))]
250 252 pub async fn quote_price(&self, cap_bytes: i64, interval: BillingInterval) -> Result<PriceQuote> {
251 253 let token = self.require_token()?;
252 254 let body = Bytes::from(serde_json::to_vec(&QuoteRequest {
@@ -268,6 +270,7 @@ impl SyncKitClient {
268 270 /// Fetch the authenticated user's email and username.
269 271 ///
270 272 /// Used by apps to display "logged in as ..." in their cloud-sync UI.
273 + #[tracing::instrument(skip(self))]
271 274 pub async fn get_account_info(&self) -> Result<AccountInfo> {
272 275 let token = self.require_token()?;
273 276 self.retry_request_json(Idempotency::Safe, || {
@@ -281,6 +284,7 @@ impl SyncKitClient {
281 284 ///
282 285 /// Returns `SubscriptionStatus` with `active: true` if sync is allowed,
283 286 /// or `active: false` if the user needs to subscribe.
287 + #[tracing::instrument(skip(self))]
284 288 pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> {
285 289 let token = self.require_token()?;
286 290 self.retry_request_json(Idempotency::Safe, || {
@@ -293,6 +297,7 @@ impl SyncKitClient {
293 297 /// Create a Stripe Checkout session for subscribing to cloud sync at the
294 298 /// chosen storage cap. Returns a URL that should be opened in the user's
295 299 /// browser.
300 + #[tracing::instrument(skip(self))]
296 301 pub async fn create_subscription_checkout(
297 302 &self,
298 303 cap_bytes: i64,
@@ -318,6 +323,7 @@ impl SyncKitClient {
318 323 /// Queue a storage-cap change that takes effect at the next billing
319 324 /// cycle. Returns the updated subscription status with the new cap in
320 325 /// `pending_storage_limit_bytes`.
326 + #[tracing::instrument(skip(self))]
321 327 pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> {
322 328 let token = self.require_token()?;
323 329 let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?);
@@ -67,8 +67,16 @@ impl CleanChanges {
67 67 );
68 68 return false;
69 69 }
70 - committed_hlc(&p.entry.table, &p.entry.row_id)
71 - .is_none_or(|committed| p.entry.hlc > committed)
70 + let apply = committed_hlc(&p.entry.table, &p.entry.row_id)
71 + .is_none_or(|committed| p.entry.hlc > committed);
72 + if !apply {
73 + tracing::debug!(
74 + table = %p.entry.table,
75 + row_id = %p.entry.row_id,
76 + "clean change gated out: older than the committed HLC"
77 + );
78 + }
79 + apply
72 80 })
73 81 .map(|p| p.entry)
74 82 .collect()
@@ -255,7 +263,7 @@ pub fn resolve_lww_at(local: &ChangeEntry, remote: &PulledChange, now: DateTime<
255 263 }
256 264 _ => {}
257 265 }
258 - match local.hlc.cmp(&remote.entry.hlc) {
266 + let resolution = match local.hlc.cmp(&remote.entry.hlc) {
259 267 std::cmp::Ordering::Greater => Resolution::KeepLocal,
260 268 std::cmp::Ordering::Less => Resolution::KeepRemote,
261 269 std::cmp::Ordering::Equal => {
@@ -265,7 +273,16 @@ pub fn resolve_lww_at(local: &ChangeEntry, remote: &PulledChange, now: DateTime<
265 273 Resolution::KeepRemote
266 274 }
267 275 }
268 - }
276 + };
277 + // Every LWW resolution discards one side; log at debug so a lost edit is
278 + // diagnosable after the fact rather than vanishing silently.
279 + tracing::debug!(
280 + table = %local.table,
281 + row_id = %local.row_id,
282 + kept = ?resolution,
283 + "LWW resolved",
284 + );
285 + resolution
269 286 }
270 287
271 288 /// Deterministic byte encoding of a row payload for the exact-HLC tiebreak.
@@ -310,7 +327,14 @@ pub fn resolve_field_merge(
310 327 ) else {
311 328 // No usable base: last-writer-wins on timestamp, keeping local when it is
312 329 // strictly newer instead of dropping it.
313 - return if local_ts > remote_ts {
330 + let keep_local = local_ts > remote_ts;
331 + tracing::debug!(
332 + keep_local,
333 + %local_ts,
334 + %remote_ts,
335 + "field-merge base unavailable; fell back to timestamp LWW"
336 + );
337 + return if keep_local {
314 338 Resolution::KeepLocal
315 339 } else {
316 340 Resolution::KeepRemote