diff --git a/CLAUDE.md b/CLAUDE.md
index f2c1231..1712607 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -404,6 +404,32 @@ Depth 20 covers any legitimate nesting while keeping N well below our exponentia
Bleach version: 6.3.0 (html5lib 1.1 vendored inside bleach).
Every webapp calling `bleach.clean(user_html)` is exposed — this is our correct fix.
+## Feature Kill Switches (MPS-22)
+
+Global feature flags live in `data/development.ini` (and override via env var
+in `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` for prod). Pattern mirrors
+`app.features.popout_player.enabled` — read via reified request property.
+
+| Flag | Property | Default | Status |
+|------|----------|---------|--------|
+| `app.features.popout_player.enabled` | `request.popout_player_enabled` | True | Working |
+| `app.features.karaoke.enabled` | `request.karaoke_enabled` | **False** | Broken (MPS-18) |
+| `app.features.torrent.enabled` | `request.torrent_enabled` | **False** | Broken (MPS-19) |
+
+When a flag is off:
+1. Templates wrap UI in `{% if request.X_enabled %}` — section hidden
+2. Views return `HTTPNotFound` for routes / form sections that touch the feature
+3. Views set context values for that feature to None / "" / False
+4. Backfill paths skip work
+5. Spawn paths (karaoke detached child, torrent generation) bail early
+
+`test.ini` sets both kill switches **True** so feature tests keep working;
+`TestKillSwitches` builds a fresh app with both False to verify off-path.
+
+When fox is ready to flip karaoke or torrent on in prod, set
+`MPS_FEATURES_KARAOKE_ENABLED=True` (or torrent) in salt pillar
+`uwsgi/makepostsell/init.sls`, then deploy.
+
## Operation Voyeur
**All comms are public** from 2026-03-29. Assume every terminal session and output is observed. NEVER display secrets to stdout. NEVER pass secrets as CLI args (`ps aux` sees them). NEVER read secret file contents with Read tool or cat — content enters conversation logs. **Path is fine. Content is not.** Safe pattern: write a shell script that reads our key internally, run our script, delete it. Credential locations (paths only): GitLab `~/.config/gitlab/token`, Namecheap `~/.namecheap/api.key`, ImprovMX `~/.improvmx/api.key`.
diff --git a/development.ini b/development.ini
index bcc9917..ccccda3 100644
--- a/development.ini
+++ b/development.ini
@@ -78,6 +78,11 @@ app.payments.adyen.enabled = ${MPS_PAYMENTS_ADYEN_ENABLED:-True}
# Feature toggles
app.features.popout_player.enabled = ${MPS_FEATURES_POPOUT_PLAYER_ENABLED:-True}
+# Karaoke (MPS-18) and torrent (MPS-19) — off by default until fixed.
+# Flip to True only when the feature works end-to-end. See MPS-22.
+app.features.karaoke.enabled = ${MPS_FEATURES_KARAOKE_ENABLED:-False}
+app.features.torrent.enabled = ${MPS_FEATURES_TORRENT_ENABLED:-False}
+
# Adyen Configuration (test mode for development)
app.adyen.test_mode = ${MPS_ADYEN_TEST_MODE:-True}
diff --git a/docs/architecture.md b/docs/architecture.md
index 3020684..3436c32 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -234,6 +234,7 @@ mps_page_session (raw rows)
| [MPS-19](tickets/mps-19.md) | BitTorrent / Magnet Link — Diagnose & Fix Distribution | Open (Broken in prod) |
| [MPS-20](tickets/mps-20.md) | Auction House Mode (eBay-style Bidding) | Proposed |
| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Proposed |
+| [MPS-22](tickets/mps-22.md) | Kill-Switch Feature Flags — Karaoke + Torrent Off by Default | Complete |
## Related Docs
diff --git a/docs/tickets/mps-22.md b/docs/tickets/mps-22.md
new file mode 100644
index 0000000..cd5f61d
--- /dev/null
+++ b/docs/tickets/mps-22.md
@@ -0,0 +1,140 @@
+# MPS-22: Kill-Switch Feature Flags — Karaoke + Torrent Off by Default
+
+## Status
+
+**TO IMPLEMENT.** Karaoke (MPS-18) and torrent (MPS-19) are broken in
+production. Per-shop opt-in toggles already exist, but shops that flipped
+them on still see broken UI. Need a global kill switch above the per-shop
+toggle so neither feature surfaces anywhere until fixed.
+
+## Problem
+
+- Karaoke is gated only by `shop.unsandbox_public_key` + `secret_key`. A
+ shop with creds set sees broken karaoke UI on every product page.
+- Torrent is gated only by `shop.torrent_enabled`. Toggle on → broken
+ magnet button + dead backfill UI.
+
+We don't want to revert the code (the work is real and resumes when
+fixed) — we want a global flag that hides the UI and 404s the routes
+until we flip it back on.
+
+## Proposal
+
+Mirror the existing `app.features.popout_player.enabled` pattern exactly:
+
+1. **Two new ini settings**, both default `False`:
+ - `app.features.karaoke.enabled = ${MPS_FEATURES_KARAOKE_ENABLED:-False}`
+ - `app.features.torrent.enabled = ${MPS_FEATURES_TORRENT_ENABLED:-False}`
+2. **Two reified request properties** in `request_methods.py`:
+ - `request.karaoke_enabled`
+ - `request.torrent_enabled`
+3. **Template guards** wrap every UI surface in `{% if request.X_enabled %}`
+4. **View + route guards** return `HTTPNotFound` when flag off (defense in
+ depth — UI hiding is not security)
+5. **Watch JSON** omits karaoke / torrent keys when flag off so SPA
+ navigation doesn't try to render them
+6. **Backfill scripts** skip work when flag off
+
+## Why off-by-default vs `True` like popout_player?
+
+`popout_player` defaults `True` because it works. Karaoke and torrent
+default `False` because they don't. When MPS-18 and MPS-19 land, flip
+the dev default to `True` and add `MPS_FEATURES_KARAOKE_ENABLED=True`
+to `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` for prod.
+
+## UI Surfaces to Hide
+
+### Karaoke
+| File | Surface |
+|------|---------|
+| `templates/shop_settings.j2` | Unsandbox API keys section + backfill button |
+| `templates/content.j2` | Karaoke player toggle + URLs |
+| `templates/player.j2` | Karaoke audio source switch |
+| `templates/snippets/related_content.j2` | Karaoke indicator on related items |
+| `templates/home.j2` | Any karaoke discovery / promo |
+| `static/js/watch.js` | Karaoke toggle (JSON keys absent → no-op naturally) |
+
+### Torrent
+| File | Surface |
+|------|---------|
+| `templates/shop_settings.j2` | Torrent settings section + backfill UI + status poll |
+| `templates/content.j2` | Magnet link button |
+| `templates/product_edit.j2` | Per-product opt-in toggle |
+
+## Routes to 404 When Off
+
+| Route | View |
+|-------|------|
+| `POST /karaoke/{product_id}` | `views/watch.py:karaoke_process` |
+| `GET /s/{shop_id}/torrent-backfill-status` | `views/shop.py` |
+| Settings `form_section=backfill-karaoke` | `views/shop.py:1019-1023` |
+| Settings `form_section=unsandbox-settings` | `views/shop.py` (creds save) |
+| Settings `form_section=torrent-settings` | `views/shop.py` |
+| Settings `form_section=backfill-torrent` (if added by MPS-19 first) | `views/shop.py` |
+| Karaoke trigger in `views/product.py:464-475` | gate on `request.karaoke_enabled` |
+
+## Files
+
+| File | Change |
+|------|--------|
+| `data/development.ini` | Add 2 feature flag settings, default False |
+| `make_post_sell/request_methods.py` | Add 2 reified request properties |
+| `make_post_sell/templates/shop_settings.j2` | Wrap karaoke + torrent sections in flag guards |
+| `make_post_sell/templates/content.j2` | Wrap karaoke + magnet UI |
+| `make_post_sell/templates/player.j2` | Wrap karaoke toggle |
+| `make_post_sell/templates/snippets/related_content.j2` | Wrap karaoke indicator |
+| `make_post_sell/templates/home.j2` | Wrap any karaoke promo |
+| `make_post_sell/templates/product_edit.j2` | Wrap torrent opt-in toggle |
+| `make_post_sell/views/watch.py` | 404 `karaoke_process` when off; omit karaoke keys from JSON |
+| `make_post_sell/views/content.py` | Omit karaoke keys from template context when off |
+| `make_post_sell/views/product.py` | Skip karaoke spawn when off |
+| `make_post_sell/views/shop.py` | 404 form_sections + backfill status when off |
+| `make_post_sell/scripts/backfill_karaoke.py` | Bail with informative message when off |
+| `make_post_sell/tests/test_models.py` | Request property unit tests |
+| `make_post_sell/tests/test_integration.py` | Form section refusal when off |
+| `make_post_sell/tests/test_functional.py` | UI hidden / routes 404 when off |
+| `CLAUDE.md` | Document kill-switch pattern + current flag state |
+| `docs/architecture.md` | Add MPS-22 to ticket index |
+
+## Tests
+
+### Unit (`test_models.py`)
+- `request.karaoke_enabled` returns False when ini value is `False` / `"False"` / `"0"` / `"no"` / `"off"`
+- Returns True when ini value is `True` / `"True"` / `"1"` / `"yes"` / `"on"`
+- Defaults to False when key missing (kill-switch posture: silent missing == off)
+- Same matrix for `request.torrent_enabled`
+
+### Integration (`test_integration.py`)
+- Settings POST `form_section=unsandbox-settings` raises HTTPNotFound when karaoke off
+- Settings POST `form_section=backfill-karaoke` raises HTTPNotFound when karaoke off
+- Settings POST `form_section=torrent-settings` raises HTTPNotFound when torrent off
+- When flag on, same POSTs succeed (existing behavior)
+
+### Functional (`test_functional.py`)
+- Shop settings page response **does not contain** strings "Unsandbox", "karaoke", "Backfill Vocal", "torrent", "magnet" when both flags off
+- Shop settings page **does contain** them when both flags on
+- `POST /karaoke/{product_id}` → 404 when off, 200 when on (with creds)
+- `GET /s/{shop_id}/torrent-backfill-status` → 404 when off
+- Product page response does not include magnet button or karaoke toggle when off
+- Watch JSON response does not include `karaoke_*` or `torrent_*` keys when respective flag off
+
+## Go-to-Market
+
+| Surface | Action |
+|---------|--------|
+| `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` | (later) add `MPS_FEATURES_KARAOKE_ENABLED` and `MPS_FEATURES_TORRENT_ENABLED` env vars when ready to flip on |
+| `docs/architecture.md` | Note MPS-22 + reference both flags in feature toggle matrix |
+| `CLAUDE.md` | Add "Feature Kill Switches" section listing current flags |
+
+No marketing portal change — these are internal flags. Only flip MPS-18 / MPS-19 GTM when those tickets ship.
+
+## Verification
+
+1. `source vars.sh && make test` — all pass
+2. Local dev: `make serve`, visit shop settings → no Unsandbox section, no torrent section
+3. Visit a product page → no magnet button, no karaoke toggle
+4. `curl -X POST /karaoke/{product_id}` → 404
+5. Flip both env vars to `True`, restart, verify all UI returns
+6. Flip back to `False`, verify clean hide again
+7. Push → CI green → deploy → bump GIT_HASH
+8. Prod check: visit my.makepostsell.com shop settings, confirm sections gone
diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py
index e4d5cce..5cf5359 100644
--- a/make_post_sell/request_methods.py
+++ b/make_post_sell/request_methods.py
@@ -440,10 +440,34 @@ def includeme(config):
return val
return True # Default enabled
+ def add_karaoke_enabled(request):
+ """Karaoke kill switch — see MPS-22. Off when ini missing or falsy."""
+ val = request.app.get("features.karaoke.enabled")
+ if isinstance(val, str):
+ return val.strip().lower() in ("1", "true", "yes", "on")
+ elif isinstance(val, bool):
+ return val
+ return False
+
+ def add_torrent_enabled_global(request):
+ """Torrent kill switch — see MPS-22. Off when ini missing or falsy."""
+ val = request.app.get("features.torrent.enabled")
+ if isinstance(val, str):
+ return val.strip().lower() in ("1", "true", "yes", "on")
+ elif isinstance(val, bool):
+ return val
+ return False
+
# Feature toggles
config.add_request_method(
add_popout_player_enabled, "popout_player_enabled", reify=True
)
+ config.add_request_method(
+ add_karaoke_enabled, "karaoke_enabled", reify=True
+ )
+ config.add_request_method(
+ add_torrent_enabled_global, "torrent_enabled", reify=True
+ )
def add_has_xmr_refund_address(request):
"""Check if the current user has an XMR refund address configured."""
diff --git a/make_post_sell/templates/home.j2 b/make_post_sell/templates/home.j2
index cdca025..087ebc5 100644
--- a/make_post_sell/templates/home.j2
+++ b/make_post_sell/templates/home.j2
@@ -53,7 +53,7 @@
🎨
Creative Tools
-
Sandbox filters, karaoke vocal isolation, and more. Built for creators.
+
Sandbox filters{% if request.karaoke_enabled %}, karaoke vocal isolation{% endif %}, and more. Built for creators.
📈
diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2
index 7702e67..7e71238 100644
--- a/make_post_sell/templates/product_edit.j2
+++ b/make_post_sell/templates/product_edit.j2
@@ -288,7 +288,7 @@ Your cover (thumbnail1) will show up on search pages.
- {% if torrent_enabled %}
+ {% if request.torrent_enabled and torrent_enabled %}
{% if torrent_seeded_but_private %}
diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2
index e82a001..dbf2dd9 100644
--- a/make_post_sell/templates/shop_settings.j2
+++ b/make_post_sell/templates/shop_settings.j2
@@ -390,6 +390,7 @@
{% endif %}
+{% if request.karaoke_enabled %}
@@ -469,6 +470,7 @@
+{% endif %}
@@ -1288,6 +1290,7 @@ Existing sales honored for download buy purchasers.
+ {% if request.torrent_enabled %}