Skip to content

Listening-time tracking: cross-platform G-Counter sync, backend, and IaC#7

Merged
JacobStephens2 merged 21 commits into
mainfrom
listening-time
Jun 5, 2026
Merged

Listening-time tracking: cross-platform G-Counter sync, backend, and IaC#7
JacobStephens2 merged 21 commits into
mainfrom
listening-time

Conversation

@JacobStephens2

Copy link
Copy Markdown
Owner

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

  • Core (cascade-core): grow-only counter accruing on the existing Tick path, gated on confirmed playback (not intent), per-tick clamp, separate persisted blob, sync-baseline/reset commands, no sync-effect-from-core. 49 tests.
  • Backend (server/): standalone Rust/Axum + SQLx + Postgres. Magic-link auth (single-use, hashed), opaque revocable sessions, G-Counter (GREATEST upsert / SUM read), /metrics. Deployed live at sync.cascade.stephens.page.
  • Shells: web + Android local tracking and account sync (runtime-verified); Windows + macOS/iOS local + sync (CI-green; on-device finishing — protocol activation/Universal Links, Keychain/DPAPI — deferred). watchOS is a thin remote.
  • Privacy: policy rewritten — nothing on servers without an account; an account holds only an aggregate counter, never a timeline.
  • Ops & docs: Terraform + Ansible deploy (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

  • Backend + web are already live (deployed from this branch). Merging is the SCM step; the release is coupled (privacy + backend + web shipped together).
  • Native shells (Windows/Apple) are compile-verified in CI only — not run on hardware.

🤖 Generated with Claude Code

JacobStephens2 and others added 21 commits June 5, 2026 17:39
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>
@JacobStephens2 JacobStephens2 merged commit db27462 into main Jun 5, 2026
4 checks passed
@JacobStephens2 JacobStephens2 deleted the listening-time branch June 5, 2026 20:36
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