Listening-time tracking: cross-platform G-Counter sync, backend, and IaC#7
Merged
Conversation
Accrue audible listening on the existing Tick path inside the reducer. The device total is one G-Counter slot: grow-only, merged server-side by max-per-device and sum-across-devices, so concurrent listening on two devices adds rather than overwrites. Design follows the model-council synthesis: - Gate accrual on a new `audio_confirmed_playing` flag (set by PlatformPlaybackStarted, cleared on pause/error/stop), not on intent, so an autoplay-blocked shell counts nothing until audio truly starts. - Clamp each tick's delta (MAX_TICK_ACCRUAL_MS = 5s) so a sleep/wake gap or clock jump can't inflate the counter — the only attack surface a G-Counter has is the input delta. - Tracking is on by default (opt-out) via SetListeningTracking. - Persist a separate `cascade.listening.v1` blob via Effect::PersistListening, independent of settings; restore via Command::RestoreListening, which never lowers a live counter (max-merge guards partial writes). - ApplySyncedTotal moves the display baseline only and never decrements the device slot; ResetListeningData zeros it (shell rotates device_id). - No sync effect is emitted from core — shells observe unsyncedMs and own their own cadence, keeping the core free of network policy. Snapshot gains a `listening` view (tracking flag, device + displayed totals, unsyncedMs, formatted label). No FFI signature changes — the new serde fields flow through the existing JSON wire shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old copy promised "no accounts" and "no servers that receive data about you," which the listening-time feature contradicts. Rewrite to the accurate posture: - Nothing is stored on a server unless the user creates an account. - Listening time is tracked on-device by default (on by default, opt-out), and is a single cumulative number — never a timeline. State plainly that no dates, times, or per-session entries are recorded, so when/how someone listened cannot be reconstructed. This is the data-minimization defensibility argument for default-on tracking. - Describe what an optional account holds: email (magic-link sign-in, no password) plus the aggregate listening total, and nothing else. - Add sign-out / delete-data / delete-account guidance. Per the synthesis, this copy is a launch gate and must deploy in the same release as the feature itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the listening feature into the web shell (local, no account yet): - Mirror the new core Commands/Effect/Snapshot in types.ts. - Persist the `cascade.listening.v1` blob on the PersistListening effect and restore it once on boot via RestoreListening — a separate slot from settings and session, owned opaquely by the core. - Widen the tick loop: it previously ran only while a timer was active, so plain playback accrued nothing. It now also ticks while audio is playing, at a coarse 1s cadence (vs 250ms for timers) to keep it cheap. The shell already dispatches PlatformPlaybackStarted after audio.start(), so accrual is correctly gated on confirmed playback. - Add a ListeningStats panel: lifetime total + an on/off tracking toggle (SetListeningTracking). Verified: 12 assertions against the node-target wasm build (accrual gating, 5s clamp, mute, restore, sync baseline, camelCase wire shape) and a headless Chrome render showing the readout and toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A standalone Rust/Axum + SQLx + Postgres service (its own cargo workspace, detached from the core so its heavy deps stay out of the shell lockfile). Implements the synthesis's backend recommendation: - Magic-link auth: POST /auth/request emails a one-time link (always 200, so it never leaks who has an account); POST /auth/verify consumes it atomically (single-use via UPDATE ... WHERE used=FALSE RETURNING) and mints an opaque server-side session token. Opaque, not JWT, so logout/delete revoke instantly. Tokens are stored only as SHA-256 hashes. - G-Counter aggregation: PUT /listening upserts a per-(user,device) slot with GREATEST(existing, incoming) and returns SUM across the user's devices; GET /listening reads the aggregate. Concurrent devices add, never overwrite. - Data minimization by construction: schema holds an email and one integer per device — no session log, no per-listen timestamps. Total is knowable, timing is not. - DELETE /listening clears counters (client rotates device_id to close the G-Counter resurrection loophole); DELETE /account cascades to sessions + counters and instantly invalidates the session. - SMTP delivery via lettre, with a log-only fallback when SMTP isn't configured. - Runtime queries (no compile-time DB needed); migrations run on boot. Verified: 3 unit tests + a full end-to-end run against a throwaway Postgres (magic-link single-use, session auth, GREATEST merge keeping the higher slot, SUM, delete-data, delete-account session revocation, 401 without a token). NOT deployed yet — must go live in the same release as the shells + privacy page. Provisioning steps (DB, subdomain, certbot, systemd, SMTP) in README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the web vertical against cascade-sync-server: - api.ts: typed client for the sync endpoints; gated on VITE_SYNC_API so the feature cleanly disappears when no backend is configured (local tracking still works). - useSync.ts: owns the optional account and the sync loop. The core stays pure — this hook reads snapshot.listening (deviceTotalMs / unsyncedMs), decides cadence (immediate on sign-in, after 30s of unsynced time, and a keepalive flush on pagehide/visibility-hidden), and folds the server aggregate back in via applySyncedTotal. Completes magic-link sign-in from a ?token= URL and strips it. A 401 drops the session locally without losing local tracking. - Device id is stored per-device and ROTATED on delete-data/delete-account, so a stale offline write can't resurrect a deleted G-Counter slot. - AccountControls: email sign-in form, signed-in state, and manage/delete actions with confirmation. Sync cadence lives entirely in the shell (no sync effect from core), per the synthesis. Verified: a real headless-Chrome magic-link sign-in against a local server (CORS + verify + signed-in render), with the DB confirming the user, session, consumed single-use token, and the browser's initial sync PUT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire listening into the Android shell (local; sync is a follow-up): - Mirror the new Commands/Effect/ListeningSnapshot in Dto.kt (the JSON wire shape — UniFFI passes it through unchanged, no binding regen needed). - Persist the listening blob in DataStore under its own key, separate from settings; restore once on startup via RestoreListening (CascadeBridgeHolder), and write it on the PersistListening effect. - Critically, wire confirmed playback: PlaybackController now attaches a Player.Listener and dispatches PlatformPlaybackStarted/Paused/Error from real Media3 state. Without this the accrual gate (which keys on confirmed audio, not intent) would never fire on Android. - Widen the tick loop: it ran only while a timer was active, so plain playback accrued nothing. It now also ticks while playing, at a coarse 1s cadence. - Add a ListeningStats row (lifetime total + tracking Switch). Verified: :app:assembleDebug builds the Rust uniffi lib + Kotlin clean. Not yet run on a device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the optional account + sync loop to the Android shell: - SyncApi: dependency-free HttpURLConnection client for the sync endpoints. - AccountStore: DataStore for session token + email + a stable device id, with rotation on delete (so a stale offline write can't resurrect a deleted slot). - SyncManager: owns account state and cadence (immediate on sign-in, after 30s of unsynced time, and a flush on onStop), folding the server aggregate back into the core via ApplySyncedTotal. A 401 drops the session locally without losing local tracking. Cadence lives in the shell, not the core. - Magic-link deep link: an autoVerify intent-filter for https://cascade.stephens.page/auth; MainActivity extracts ?token= on create/new-intent and completes sign-in. (assetlinks.json on the host is a deploy step for chooser-free opening.) - AccountControls UI: email sign-in, signed-in state, manage/delete actions. Sync target is SYNC_API_BASE (the planned sync subdomain); the feature is opt-in so no network call happens until a user signs in. Verified: :app:assembleDebug builds clean. Not yet run on a device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire listening into the WinUI shell (local; account sync is a follow-up): - Mirror the new Commands/Effect/ListeningSnapshot in Dto.cs (JSON passes through the C ABI unchanged). - Persist the listening blob to %LOCALAPPDATA%\Cascade\listening.json, separate from settings; restore once at startup via RestoreListening, and write it on the PersistListening effect. - Widen the tick loop: it ran only while a timer was active, so plain playback accrued nothing. It now also ticks while playing, at a coarse 1s cadence (TickScheduler takes an interval; AppViewModel restarts it when the cadence changes). Confirmed-playback is already reported (PlatformPlaybackStarted after StartPlayback), so accrual is correctly gated. - Add a lifetime readout + a Tracking on/off toggle to MainWindow. Compile-verified only via the Windows CI workflow (cannot build on the Linux dev host). Not run on Windows hardware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire listening into the shared Apple core and the macOS + iOS UIs (local; account sync is a follow-up): - Mirror the new Commands/Effect/ListeningSnapshot in the shared Dto.swift (JSON passes through UniFFI unchanged). - Persist the listening blob to Application Support/Cascade/listening.json, separate from settings; restore once at bootstrap via RestoreListening, and write it on the PersistListening effect. - Widen the shared tick loop: it ran only while a timer was active, so plain playback accrued nothing. It now also ticks while playing, at a coarse 1s cadence (startTicking takes an interval; apply() restarts on cadence change). Confirmed-playback is already reported, so accrual is correctly gated. - Add a lifetime readout + tracking Toggle to the macOS main window and the iOS screen. watchOS needs no change: it's a thin remote that renders a mapped subset snapshot and owns no slot — exactly the recommended design (no double-counting between wrist and phone). Compile-verified only via the Apple CI workflow (no macOS/Xcode on the Linux dev host). Not run on Apple hardware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CommunityToolkit.Mvvm 8.4 flags [ObservableProperty] on fields (snapshot, errorMessage) as deprecated in favor of partial properties, but those need C# 13 / the .NET 9 SDK and CI builds with the .NET 8 SDK (C# 12), where they don't compile. Suppress the forward-compat warning until this target moves to .NET 9; the field syntax is correct and functional today. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Instrument cascade-sync-server with the idiomatic metrics + metrics-exporter-prometheus stack: - A request-counting middleware records cascade_sync_http_requests_total and cascade_sync_http_request_duration_seconds, labelled by method, matched route template (not raw path, so cardinality stays bounded), and status. - GET /metrics renders the Prometheus exposition. It's registered after the counting layer so 15s scrapes don't dominate the counters, and it carries only operational metrics — no PII, consistent with the data-minimization posture (an attacker scraping it learns request rates, never who has an account). - Exposed on the localhost bind only; the Apache vhost must not proxy it. Verified locally: per-route counters and the latency histogram render, and /metrics does not count itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the by-hand deploy with Infrastructure as Code for the listening-time sync service — real, load-bearing infra rebuilt from text and reviewable in a PR. Terraform (server/deploy/terraform): - Creates the DNS A record for sync.cascade.stephens.page and references the existing droplet read-only. Deliberately additive: an apply cannot recreate or destroy load-bearing infra. Documents the `terraform import` path for bringing the existing droplet/volume/firewall under management incrementally. Ansible (server/deploy/ansible), role `cascade_sync`, idempotent: - Creates a dedicated cascade_sync Postgres role + database. - Installs a systemd unit that runs the binary as an unprivileged user with filesystem/syscall hardening (ProtectSystem=strict, empty CapabilityBoundingSet, MemoryDenyWriteExecute, …) — the runtime half of the threat model. - Installs an Apache reverse-proxy vhost that refuses to expose /metrics. - Obtains TLS via certbot --apache (idempotent). - Renders the env file; DB password + SMTP creds come from an ansible-vault file, the DO token from the environment. Nothing secret is committed. Validated: all Ansible YAML parses, Jinja/HCL braces balance. Full `terraform validate` / `ansible-playbook --syntax-check` should run on a host with the toolchains installed. A sanitized copy is destined for the infrastructure-patterns repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes found during the live deploy: - mail.rs: select the SMTP transport by port. relay() does implicit TLS (port 465); 587/submission needs STARTTLS. Using relay() on 587 produced "received corrupt message of type InvalidContentType" against Resend. Now 465 -> relay(), everything else -> starttls_relay(). Verified: magic links send through smtp.resend.com:587 in production. - apps/web/.env.production: set VITE_SYNC_API to the deployed sync subdomain so `vite build` ships the account/sync feature in the production bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The role was authored against Rocky/httpd, but the real fleet (and the live hand-deploy) is Ubuntu + Apache 2.4. Make the artifact truthful and runnable on the actual host: - defaults: apache2 service, /etc/apache2/sites-available confdir, and an apache_use_a2ensite toggle (set false + httpd confdir for a Rocky host). - tasks: enable proxy + proxy_http via apache2_module, and a2ensite the vhost (guarded by the sites-enabled symlink so it's idempotent). This mirrors the steps actually used to deploy sync.cascade.stephens.page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ana) Monitoring-as-code consuming the live /metrics endpoint: - prometheus/cascade-sync.scrape.yml — scrape the app on localhost plus an external blackbox HTTPS probe of /health (catches the Apache/TLS/DNS layer the app can't self-report). - prometheus/cascade-sync.rules.yml — alerts: service down, external probe failing, TLS cert expiring <7d, 5xx rate >5%, mean latency >1s. - grafana/cascade-sync-dashboard.json — import-ready dashboard: up, probe, cert days remaining, request rate by route, status mix, error ratio, mean latency. Replaces ad-hoc health-curling with the standard stack. JSON/YAML validated; provisioning via an Ansible observability role is documented for when the Prometheus/Grafana stack is stood up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A workflow_dispatch pipeline that builds the cascade-sync-server release binary (cargo test + build), uploads it, then deploys via the Ansible role behind a protected `production` environment with a required reviewer — the GitOps-flavored counterpart to the hand-rolled web deploy script. Manual-only so a deploy is always deliberate; concurrency-guarded so two deploys can't overlap. Secrets (SSH key/known_hosts, deploy host, vault password) live on the environment. Documented in the workflow header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document what the service protects, against whom, and the residual risk — mirrors the muxboard threat-model artifact. Centers on data-minimization-by- construction (the schema can't express a listening timeline) as the control that bounds every other threat, then walks T1–T12 (enumeration, hashed tokens, single-use magic links, opaque revocable sessions, CSRF/SQLi/CORS, deletion resurrection, metrics disclosure, systemd hardening, secrets, counter inflation) and names the real gaps: no rate limiting (R1), localStorage tokens (R2), no audit log (R3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An architecture decision record covering the six linked decisions behind cross-platform listening time: pure-core accrual gated on confirmed playback, a G-Counter CRDT (max-per-device / sum-across-devices, reject LWW), data- minimization-by-construction (no timeline), sync cadence in the shells, opaque magic-link auth, and device_id rotation on delete. Includes alternatives considered and validation. Destined to mirror into infrastructure-patterns as a senior architect-thinking artifact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Narrative writeup — "Tracking Listening Time Across Six Platforms Without Building a Surveillance Tool": the one-core/six-shells shape, confirmed- playback gating, the G-Counter (why latest-total is wrong), data-minimization by construction (the schema can't store a timeline), opaque magic-link auth + device_id rotation on delete, and cadence-in-shells. Staged here as publish-ready HTML matching the blog.stephens.page template (title/center/footer classes, OG tags). NOT pushed to the live blog — drop it into blog.stephens.page/posts/ + add the index entry when ready to publish. Elevates the Cascade "buried gem" per the advancement plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the Windows sync vertical (compile-verified via CI): - SyncApi: typed System.Net.Http client for the sync endpoints. - AccountStore: session token + email + a stable device id under LocalAppData, with rotation on delete (so a stale write can't resurrect a deleted slot). - AppViewModel: account state + relay commands (request link, complete sign-in, sign out, delete data/account) and a SyncAsync loop folding the server aggregate back via ApplySyncedTotal. Cadence in the shell: immediate on sign-in + once 30s of unsynced time accrues; a 401 drops the session locally. - MainWindow: a "sync across devices" panel (email + paste-the-link sign-in, signed-in state, manage/delete). Desktop protocol-activation (open the app from the https link) and DPAPI/ Credential Locker token storage are the on-device finishing — the scaffold uses a paste-the-link flow and a LocalAppData file. Not run on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the Apple sync vertical in the shared layer (compile-verified via CI): - SyncApi: async URLSession client for the sync endpoints. - AccountStore: session token + email + a stable device id in UserDefaults, rotated on delete (closes the G-Counter resurrection loophole). - AppStore: account state + async methods (signIn, completeSignIn, signOut, deleteListeningData, deleteAccount) and a sync() that folds the server aggregate back via ApplySyncedTotal. Cadence in the shell: immediate on sign-in + once 30s of unsynced time accrues; a 401 drops the session. - AccountControlsView: shared SwiftUI panel (email + paste-the-link sign-in, signed-in state, manage/delete), added to the macOS + iOS screens. - .onOpenURL routes a magic-link into handleOpenURL for when Universal Links are configured. New files live under CascadeShared/Sync (compiled into Mac + iOS by xcodegen; the watch target only includes CascadeShared/Watch, so it stays a thin remote). Keychain token storage + Universal Links are the on-device finishing. Not run on Apple hardware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds cross-device listening-time tracking to Cascade: a per-device G-Counter CRDT that accrues in the pure Rust core, an optional account to centralize it, and a small self-hosted sync service — designed so it structurally cannot record a listening timeline.
What's in here
cascade-core): grow-only counter accruing on the existingTickpath, gated on confirmed playback (not intent), per-tick clamp, separate persisted blob, sync-baseline/reset commands, no sync-effect-from-core. 49 tests.server/): standalone Rust/Axum + SQLx + Postgres. Magic-link auth (single-use, hashed), opaque revocable sessions, G-Counter (GREATESTupsert /SUMread),/metrics. Deployed live atsync.cascade.stephens.page.server/deploy/), Prometheus/Grafana observability, an approval-gated deploy workflow, a threat model, and an ADR. A focused security review found no new vulnerabilities.Status
🤖 Generated with Claude Code