Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 1c2dcbaa5456f3903606d3d13a56c7661366fdda)
+++ src/routes/posts.js	(revision 5a27ba489c6b75d0e23e4c8d40f3efc5b4b27c15)
@@ -212,11 +212,13 @@
 // attachment). NB: the file gate is per file, so opening a track in one post makes its file
 // fetchable for every post that reuses it.
+// ONE-WAY: opening is permanent. Once the file has federated it's out there — re-gating
+// would be false security (remote copies keep the URL), so we never write fedi_open back to 0.
 function setAudioFediOpen(siteId, content, open) {
-  const val = open ? 1 : 0;
+  if (!open) return; // never close — see one-way note above
   const c = content || '';
   try {
-    for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = ? WHERE id = ? AND site_id = ?').run(val, m[1], siteId);
-    for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = ? WHERE site_id = ? AND album = ?').run(val, siteId, m[1].trim());
-    for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = ? WHERE id IN (SELECT track_id FROM playlist_tracks WHERE playlist_id = ?)').run(val, m[1]);
+    for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE id = ? AND site_id = ?').run(m[1], siteId);
+    for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND album = ?').run(siteId, m[1].trim());
+    for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE id IN (SELECT track_id FROM playlist_tracks WHERE playlist_id = ?)').run(m[1]);
   } catch { /* non-fatal */ }
 }
