JW Labs JW Labs LLC · a software studio· 4a· Privacy policy

4a — Privacy Policy

Published from source. This page is generated from docs/legal/privacy-policy.md in the foray repository — the same repository that builds 4a — at commit 21ff5c5, snapshotted 2026-08-24. The repository copy is authoritative; if the two ever differ, that one is right and this one is stale. Internal editorial notes were removed at publication and §9 was completed with the publication facts. No other wording was changed.

Last updated: 2026-08-19 · Applies to: the 4a web app (https://jw-incorporated.github.io/foray/) and the iOS/Android app built from the same code. The app was formerly Foray. That is why the word is still in this URL and in the names of the local database and the cache bucket §1 describes: renaming the database would orphan data already on your device, and renaming the other two would break saved links and make every listener re-download the app shell, for no benefit to you. Separately, a "foray" is what the app calls one assembled run of segments — that is the sense the cp_foray: keys in §1 use, and it is unchanged.

This document was written by reading the shipped code, not from a template. Every claim below has a file and line reference in data-safety.md, which answers Google Play's and Apple's declaration forms question by question. If you change what the app collects, change both files in the same PR.


The short version

4a is a podcast curator. It picks episodes and assembles them into a "foray" — an ordered run of segments drawn from real podcast episodes.


1. What stays on your device

4a keeps its state under keys beginning cp_. Nearly every key is written to two places on your device: localStorage and an IndexedDB database (name foray, object store kv). The second copy exists because browsers evict localStorage — Safari clears script-writable storage after about seven days without a visit — and losing it would silently orphan your profile. localStorage is kept as a mirror, not a staging area; nothing is deleted to migrate it. (player/durable-store.js, player/idb-tier.js.)

Two honest qualifications to "two places". The diagnostic record cp_storage_health is deliberately never written to IndexedDB — a failing durable write is exactly what it records, so queueing it there could make the diagnostic the outage (player/durable-store.js:_recordHealth() mirrors the record into the synchronous tiers only). And until the player module has loaded, writes go to plain localStorage only; if that module fails to load, they stay there for the session (app.js:storageBackend()).

Two of these keys are diagnostics rather than your data, and neither is transmitted. cp_storage_health records storage failures. cp_diag records how the audio player behaved — see its row below — and exists because two playback faults were reported from a car with no measurements attached, so there was nothing to diagnose them with. It is capped, the oldest entries are dropped first, and the drawer's Playback diagnostics is where you read it, copy it or clear it (player/diagnostic-log.js).

The app also asks the browser to mark its storage as persistent (navigator.storage.persist()), and records the answer rather than assuming it.

KeyWhat it holdsDoes it leave your device?
cp_interestsA weight from 0 to 1 for each topic in the taxonomy — the learned interest profileNo
cp_historyThe last 200 episode ids you picked or played in the appNo (but see picked in §2)
cp_seenEpisode ids already shown to you, so they are not repeatedNo
cp_savedThe episodes you savedNo (but see saved in §2)
cp_lastpickA snapshot of the last episode you pickedNo (but marking it Done sends finished — §2)
cp_playlistsPlaylists you built, including the text you typed to build them. Since 2026-08-19 each part also keeps a copy of the episode's own details — its id, title, show name, length, Apple Podcasts ids and topic ids — so a playlist still lists what is in it after the episode leaves 4a's catalogue. It deliberately does not copy the audio URL or the artwork URLNo
cp_questsA legacy key, migrated once into cp_playlistsNo
cp_recent_branchesWhich topic branches you recently came fromNo
cp_foray:<id>Where you are inside a given foray, and which segment you were inNo
cp_pos:<id>Your position in seconds inside an individual episodeNo
cp_rateYour playback speedNo
cp_playerWhich external podcast app you prefer to open episodes inNo
cp_familyFamily mode on/off — a local content filter that hides explicit-rated episodesNo
cp_intro_dismissedWhether you dismissed the intro cardNo
cp_foray_feedbackYour per-segment thumbs: direction, reason codes, any note you typed, timestampYes, via thumbs — see §2
cp_eventsA rolling buffer of the last 5,000 eventsPartly — 5 of the 18 event types are sent; see §2
cp_synced_tsA bookmark recording which events have already been sentNo
cp_profile_idA random local id (e.g. p-a1b2c3d4...) generated on this deviceNo — it is stamped on local events but is not included in anything sent
cp_sb_sessionThe access and refresh token for your anonymous account, and its user idIt is your credential for our database — see §3
cp_storage_healthA diagnostic record of storage failures, for troubleshootingNo
cp_diagA playback diagnostic record, capped at the most recent 200 entries: how long each seam between two segments took, the load deadline in force, out-point overshoot, stops (a lost audio route, an interruption), which resume point was written and read back, when the app went to the background and for how long, and any press of a play or transport control that failed — with the class of the error (for example NotAllowedError, meaning your browser held the audio back), never its message, and with a count when the same press fails repeatedly. It holds no audio, no URLs, no account id and no device names — when it records that a known audio route came back, it records only that one was recognised, never whichNo — it is never transmitted; the drawer's Playback diagnostics shows it and lets you copy or clear it

The web app also keeps a Cache Storage bucket named foray-v5 holding the app shell and the catalogue JSON files, so the app renders in a dead zone (sw.js). It never caches podcast audio, because the service worker ignores every request that is not to our own origin.

2. What leaves your device, exactly

The app buffers events locally and periodically sends some of them to our database (Supabase — see §3). Thirteen of the eighteen event types the app records never leave the device. The buffer is trimmed to the most recent 5,000 entries.

Sent (app.js:toEventRow()). Every row carries your anonymous account id and a timestamp:

EventFields sent
pickedEpisode slug, its topic ids, an app label, and a context label. See the note below — both labels carry less about you than their names suggest
finishedEpisode slug, topic ids, and a completion marker. Marked manual_stopgap because on the web you press Done — the app cannot observe real playback in an external app
savedEpisode slug, topic ids
thumbsUp or down; the taxonomy node it applies to; optionally the episode slug, segment id and foray id; the reason codes you selected; and the free-text note you typed (a single line, up to 200 characters)
session_shown → stored as session_builtA session key and which builder produced it

Not sent — recorded only on your device: play_started, position (your play position; stored about every 15 seconds, recorded as an event at most once a minute per episode — player/position-store.js:save()), foray_play, foray_restart, foray_progress_drift, source_opened, saved's counterpart unsaved, playlist_built, playlist_removed, player_pref, family_mode, refreshed_all, storage_fault.

The picked row's two labels are narrower than they sound, and we would rather say so than let the field names imply more collection than happens:

Two things worth calling out plainly, because a generic policy would hide them:

Nothing you type into the playlist box is transmitted. That search runs entirely on your device against files already downloaded (search-engine.js); playlist events are local-only.

3. The anonymous account

4a has no signup, no password, no email and no profile. On the first page load that produces an event, the app asks Supabase — our hosted database provider — to create an anonymous account. Supabase issues a user id and a token, which are stored in cp_sb_session on your device. Every row we store is keyed to that id, and the database's row-level security means a client holding your token can only read and write your own rows (backend/migrations/supabase/0001_auth_and_rls.sql).

That account contains no name, no email address, no phone number and no password. It is an opaque identifier. If you clear the app's storage, the token is gone and the app creates a new anonymous account the next time it needs one — the old rows remain but nothing on your device points to them any more.

That is exactly why the delete control in §7 deletes the rows first and the token second. It also cuts the link deliberately: after a deletion the app starts a new anonymous account rather than re-attaching you to the old one. The old account row itself stays, because removing it needs an administrative key we do not ship in a public web page — §7 says so plainly.

Because it is an ordinary network request, Supabase necessarily observes the IP address it came from, as any server does.

4. What your device contacts directly — and we never see

This is the most important thing about how 4a is built, and it cuts both ways.

Product principle 3 says we never rehost, proxy or transform episode audio. The app honours that literally: it sets an <audio> element's src to the publisher's own enclosure URL and plays it (player/html-audio-backend.js:load()). There is no 4a server in the path.

The upside is real: we cannot build a listening profile out of your audio requests, because they never touch us. The corresponding disclosure is equally real: your device talks straight to the publisher's host, so that host sees your IP address, your user-agent, and which episode you requested, at the time you requested it. What they do with it is governed by their privacy policy, not ours.

4.1 How many parties, and who they are

As of 2026-08-17 the catalogue the app downloads points at 43 distinct hosts for audio, across the three data files the app fetches on load (data/segment-sources.json — the assembled forays; data/session.json — the home cards; data/discover.json — the recommendation pool, ~1,480 playable items). Anything with a play button plays this way.

2.gum.fm  anchor.fm  aphid.fireside.fm  api.substack.com  archive.org
audioboom.com  cdn.simplecast.com  chrt.fm  claritaspod.com  clrtpod.com
content.rss.com  dts.podtrac.com  episodes.captivate.fm  episodes.castos.com
feeds.soundcloud.com  injector.simplecastaudio.com  mcdn.podbean.com
media.blubrry.com  media.transistor.fm  mgln.ai  op3.dev  pdcn.co  pdrl.fm
pdst.fm  pfx.vpixl.com  pinecast.com  podcasts.captivate.fm  podtrac.com
prefix.up.audio  prfx.byspotify.com  pscrb.fm  redirect.zencastr.com
rss.art19.com  s.gum.fm  sphinx.acast.com  static1.squarespace.com
stitcher.simplecastaudio.com  tracking.swap.fm  traffic.libsyn.com
traffic.megaphone.fm  traffic.omny.fm  www.buzzsprout.com  www.podtrac.com

This list is generated from the data files, and it changes when the catalogue does. Treat it as accurate for the date above, and regenerate it rather than editing it by hand.

4.2 Many of these are measurement and ad-attribution services

This is the part a template would never tell you, and it is the honest reason this section is long.

A publisher typically does not point at their audio file directly. They put one or more prefix services in front of it, each of which logs the request and then redirects to the next. Your device follows every hop, so each one sees your IP address and user-agent. A real URL from our own catalogue:

https://2.gum.fm/op3.dev/e/pdcn.co/e/pdst.fm/e/dts.podtrac.com/redirect.mp3/media.transistor.fm/…

That is five intermediaries before the audio. Another: mgln.ai/e/1143/media.blubrry.com/content.blubrry.com/….

Some of these prefixes count downloads (podtrac.com, op3.dev). Others are advertising-attribution services whose purpose is to connect a podcast impression to later behaviour — pdst.fm, pdcn.co and pdrl.fm (Podsights), chrt.fm (Chartable), pscrb.fm (Podscribe), claritaspod.com and clrtpod.com (Claritas), prfx.byspotify.com, tracking.swap.fm, pfx.vpixl.com, and the gum.fm hosts.

We want to be exact about what that does and does not mean.

Because every hop is a redirect decided at request time, we cannot enumerate the full chain in advance — the list in §4.1 is the first hop of each URL, and the real chain can be longer.

4.3 Artwork, and the page itself

The app also loads cover artwork over HTTPS from publisher and Apple-hosted image URLs, which reveals the same kind of request metadata to those hosts.

Finally, the web app is served from GitHub Pages, so GitHub serves the page and the catalogue files and sees those requests. In the native app the shell and catalogue are bundled, so this does not apply there.

5. What 4a does not do

Verified by reading the client, not by assertion. The app's Content Security Policy (index.html) is the structural reason most of this list is not merely a promise: connect-src names only two origins — the app's own, and our Supabase project — so any data-sending request of the kind an API call, an analytics beacon or a crash report needs is blocked by the browser unless the policy is changed in code.

To be precise rather than flattering: the same policy also allows img-src https: and media-src https:, which is any HTTPS host. That is exactly how §4's audio and artwork work, and it means the CSP is not a total network seal. It is a tight bound on the channel that would carry data out.

6. Children

4a is a general-audience podcast app and is not directed to children. We do not ask for, or knowingly collect, anyone's age. "Family mode" is only a local content filter that hides explicit-rated episodes — it collects nothing and sends nothing.

7. How to delete your data

Use the button. Open the menu (☰) and choose Delete my data. The sheet lists what the deletion covers, and you confirm by typing DELETE (capitals optional) — one stray tap cannot trigger it.

What it deletes:

What it deletes in what order, and why that matters. The server rows go first. Your token (cp_sb_session) is the only thing that can reach them, and clearing your device destroys it — so if the server cannot be reached, the app tells you your rows were not deleted and leaves your device untouched so you can try again. It will not report success for something it did not do. (If you would rather clear this device anyway, the sheet offers that as a separate choice, and says plainly that the server rows remain.)

What it cannot delete, stated rather than implied:

Clearing site data still works and is the belt to this braces — in your browser's settings for the origin the web app is served from, jw-incorporated.github.io (web), or by deleting the app (iOS / Android). It removes the local copies but not the server rows, for the reason in the paragraph above.

The foray-v5 Cache Storage bucket is not touched by the button: it holds the app shell and the catalogue files (§1), which are the same for every listener and say nothing about you.

8. Changes to this policy

If the app starts collecting something new, this document and data-safety.md must be updated in the same change, and the store declarations resubmitted. data-safety.md § "What would change these answers" lists the specific changes that would invalidate a published declaration.

9. Who we are, and how to reach us

4a is made and published by JW Labs LLC, which is responsible for the data described above.

How to reach us about privacy: help@jwlabs.ai. That address is read directly; there is no support queue in front of it.

To delete your data, use the app, not this address. §7 describes the in-app control, and it reaches everything we can reach — both storage layers on your device and all of your rows on our server — which an email cannot do faster or more completely. §7 is equally exact about the three things no deletion can reach. Write to us if the control reports that it could not finish.

Where this policy lives. It is published at https://jwlabs.ai/4a/privacy/ and takes effect on the "Last updated" date at the top of this page.

What this document is, and is not. It was written by reading the shipped code, and it is accurate about what the app does. It is not a lawyer's opinion about sufficiency under any particular law, and it does not yet state a retention period for the event rows in §2, because no retention job has been built and we would rather say nothing than state a period we do not enforce.