Skip to content

Build channels pipeline — implementation plan

Build Channels Pipeline Implementation Plan

Section titled “Build Channels Pipeline Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. In this repo, code-writing subagents are bh-implementer (model opus), and each branch is reviewed by bh-reviewer before merge.

Goal: Separate dev / beta / store builds so that:

  • a dev install never replaces the store app;
  • one build number means one thing everywhere;
  • every store upload is tagged;
  • telemetry and remote config know which channel a request came from;
  • dev-only modes can’t ship.

Architecture:

  • One shared script bumps the only build counter (Sim_Const.BUILD) into all four export presets. release.sh uses it and tags store-N.
  • A dev install script reuses the signed Xcode projects, overriding the bundle id, name and icon at xcodebuild time.
  • A small BuildChannel class (render/net side, never in /sim) picks dev/testflight/appstore/web. Every telemetry/feedback/config request carries it, and the Worker stores it and can serve per-channel config patches.
  • DevFlags replaces the dev-only consts in main.gd with runtime flags. They’re forced off outside dev builds and guarded by a test.

Tech Stack: Godot 4.7 typed GDScript + GUT, bash, Cloudflare Worker (JS, node --test), D1, KV, Xcode xcodebuild/devicectl.

Spec: docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md (read its “Amendments” section: this plan implements the amended version).

  • Nothing in this plan touches /sim except the const BUILD line in sim/constants.gd. The determinism baselines must stay green without a re-pin.
  • /sim never references BuildChannel or DevFlags (both use OS/FileAccess, which is render-side only).
  • Channel values are exactly dev, testflight, appstore, web. Any detection failure yields appstore.
  • Dev bundle id: digital.lumara.bulletheaven.dev. Dev display name: Dark Cosmos Dev. Store bundle id stays digital.lumara.bulletheaven.
  • Store build number (application/version in the tvOS/iOS presets) == Sim_Const.BUILD. Tags are store-<BUILD>.
  • release.sh runs only from main or release/*, with a clean tree.
  • Run the full gate bash scripts/gate.sh before every merge. Commit any project.godot edit before running the gate: gate.sh:32 runs git checkout -- project.godot and silently discards uncommitted edits.
  • Single GUT script: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<script_name_without_.gd> -gexit (-gtest does NOT narrow the run).
  • Worker tests: cd telemetry && npm test. Script tests: node --test scripts/test/.
  • Worker deploy, D1 migration and device installs are outward-facing. Confirm with Chris on the day before running them, and serialize deploys.
  • Commit messages end with the session’s attribution lines (see the repo’s recent commits).
  • git push origin main after every merge.
File Responsibility
scripts/bump-build.sh (new) The ONLY way to bump the build number: Sim_Const.BUILD+1 → all four presets’ application/version. Prints the new N.
scripts/release/release.sh (modify) No argument; branch/clean guards; calls bump-build.sh; tags + pushes store-N after a successful upload.
scripts/dev/install-dev.sh (new) Build + install the Dark Cosmos Dev app to a named device (atv/iphone/ipad).
scripts/dev/make-dev-icon.sh (new) Creates the badged AppIcon-Dev.appiconset inside the (gitignored) iOS Xcode shell from its AppIcon.appiconset.
net/build_channel.gd (new) class_name BuildChannel: pure classify() core + current() (OS-backed, cached).
meta/dev_flags.gd (new) class_name DevFlags: static runtime dev flags, loaded from user://dev_flags.json only in dev builds.
tests/test_build_channel.gd, tests/test_dev_flags.gd, tests/test_release_presets.gd (new) GUT coverage.
net/telemetry.gd, net/gameplay_telemetry.gd, net/dash_encounter_telemetry.gd, net/remote_config.gd (modify) Add channel to every request.
telemetry/src/worker.js, telemetry/schema.sql, telemetry/test/channel.test.mjs (new test) Store channel; per-channel config resolution.
scripts/publish-config.mjs (+ its test) --channel <c> publishes to KV key build-N@<c>.
main.gd, ui/debug_settings_panel.gd (modify) Consume DevFlags; delete dead SHIP3D_MEASURE/MEASURE_FULL_QUALITY.
.claude/skills/bh-deploy/SKILL.md, .claude/skills/bh-appstore-release/SKILL.md, docs/architecture/dev-tools.md, docs/architecture/remote-config.md Docs follow the code.

Task 1: One build counter (bump-build.sh) + preset guard test

Section titled “Task 1: One build counter (bump-build.sh) + preset guard test”

Files:

  • Create: scripts/bump-build.sh
  • Create: tests/test_release_presets.gd
  • Modify: sim/constants.gd:6 (comment only)
  • Modify: platform/tvos/export_presets.cfg (application/version of all four presets → "206")

Interfaces:

  • Produces: bash scripts/bump-build.sh prints the new build number N on its last stdout line and exits 0. It edits sim/constants.gd and platform/tvos/export_presets.cfg. It does NOT commit.

  • Produces: bash scripts/bump-build.sh --sync writes the current BUILD (no increment) into all four presets. Used once here, and available for repair later.

  • Step 1: Write the failing test tests/test_release_presets.gd

extends GutTest
# Structural guard for the dev/beta/store split (spec 2026-09-28-build-channels-pipeline-design.md).
# Parses the tracked export presets directly: a store preset that carries the "dev" feature, a dev
# preset that signs as the store app, or a store build number that drifted from Sim_Const.BUILD all
# fail here instead of on a player's device.
const PRESETS := "res://platform/tvos/export_presets.cfg"
const STORE_ID := "digital.lumara.bulletheaven"
func _presets() -> Array[Dictionary]:
var cf := ConfigFile.new()
assert_eq(cf.load(PRESETS), OK, "export_presets.cfg must be readable at " + PRESETS)
var out: Array[Dictionary] = []
var i := 0
while cf.has_section("preset.%d" % i):
var s := "preset.%d" % i
var o := s + ".options"
out.append({
"name": str(cf.get_value(s, "name", "")),
"features": str(cf.get_value(s, "custom_features", "")),
"bundle": str(cf.get_value(o, "application/bundle_identifier", "")),
"version": str(cf.get_value(o, "application/version", "")),
})
i += 1
return out
func test_there_are_four_presets() -> void:
var names := _presets().map(func(p): return p.name)
assert_eq(names, ["tvOS", "iOS", "tvOS-Dev", "iOS-Dev"])
func test_store_presets_never_carry_the_dev_feature() -> void:
for p in _presets():
if not p.name.ends_with("-Dev"):
assert_false("dev" in p.features.split(","), "%s must not carry the dev feature" % p.name)
func test_dev_presets_carry_the_dev_feature() -> void:
for p in _presets():
if p.name.ends_with("-Dev"):
assert_true("dev" in p.features.split(","), "%s must carry the dev feature" % p.name)
func test_every_preset_version_equals_sim_build() -> void:
for p in _presets():
assert_eq(p.version, str(Sim_Const.BUILD), "%s application/version must equal Sim_Const.BUILD (run scripts/bump-build.sh)" % p.name)
func test_store_presets_use_the_store_bundle_id() -> void:
for p in _presets():
if not p.name.ends_with("-Dev"):
assert_eq(p.bundle, STORE_ID, p.name)

(The dev presets keep the store id in the file on purpose. The dev id is applied at xcodebuild time by scripts/dev/install-dev.sh, Task 3, because the presets share one Xcode project.)

  • Step 2: Run it and confirm it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_release_presets -gexit Expected: test_every_preset_version_equals_sim_build FAILS (presets say 37/17, BUILD is 206). The others pass. If cf.load fails instead, Godot can’t read under platform/tvos/. Stop and report; don’t work around it.

  • Step 3: Write scripts/bump-build.sh
#!/usr/bin/env bash
# The ONLY way to change the build number (spec 2026-09-28-build-channels-pipeline-design.md §2).
# Sim_Const.BUILD is the single counter; store build (CFBundleVersion) == BUILD for every preset.
# scripts/bump-build.sh BUILD += 1, write it into all four presets, print N
# scripts/bump-build.sh --sync write the CURRENT BUILD into all four presets (no increment)
# Edits sim/constants.gd + platform/tvos/export_presets.cfg; never commits.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd); cd "$ROOT"
C=sim/constants.gd
P=platform/tvos/export_presets.cfg
old=$(grep -oE '^const BUILD: int = [0-9]+' "$C" | grep -oE '[0-9]+$')
[ -n "$old" ] || { echo "::error:: no 'const BUILD: int = N' in $C" >&2; exit 1; }
if [ "${1:-}" = "--sync" ]; then new=$old; else new=$((old+1)); fi
sed -i '' -E "s/^const BUILD: int = $old\b/const BUILD: int = $new/" "$C"
grep -qE "^const BUILD: int = $new\b" "$C" || { echo "::error:: BUILD bump failed" >&2; exit 1; }
awk -v n="$new" '/^application\/version=/{$0="application/version=\"" n "\""} 1' "$P" > "$P.tmp" && mv "$P.tmp" "$P"
[ "$(grep -c "^application/version=\"$new\"" "$P")" = 4 ] || { echo "::error:: expected 4 presets at version $new" >&2; exit 1; }
echo "$new"

chmod +x scripts/bump-build.sh

  • Step 4: Fix the BUILD comment and sync the presets

In sim/constants.gd:6 replace the trailing comment so the line reads:

const BUILD: int = 206 # change ONLY via scripts/bump-build.sh; == store build number from 207 on (206 = store build 37)

Run: bash scripts/bump-build.sh --sync. Expected output: 206, and grep -c 'application/version="206"' platform/tvos/export_presets.cfg prints 4.

  • Step 5: Run the test and confirm it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_release_presets -gexit Expected: 5/5 pass.

  • Step 6: Check that bump-build.sh increments and restores cleanly

Run: bash scripts/bump-build.sh && git diff --stat && git checkout -- sim/constants.gd platform/tvos/export_presets.cfg && bash scripts/bump-build.sh --sync Expected: first prints 207 and the diff shows 2 files; after the checkout + sync, the presets are back at 206 (git diff shows only the Step 4 changes).

  • Step 7: Commit, then run the full gate
Terminal window
git add scripts/bump-build.sh tests/test_release_presets.gd sim/constants.gd platform/tvos/export_presets.cfg
git commit -m "feat(release): single build counter (bump-build.sh) + preset guard test"
bash scripts/gate.sh

Expected: GATE PASS.


Task 2: release.sh — no argument, branch guard, store-N tag

Section titled “Task 2: release.sh — no argument, branch guard, store-N tag”

Files:

  • Modify: scripts/release/release.sh (whole file)
  • Modify: .claude/skills/bh-appstore-release/SKILL.md (§0 and the “Scripted since” note)

Interfaces:

  • Consumes: scripts/bump-build.sh (Task 1) → prints N.

  • Produces: scripts/release/release.sh (no args). Env EXTERNAL=1, NO_UPLOAD=1 as before. On a successful upload it creates and pushes annotated tag store-N. With NO_UPLOAD=1 it never tags.

  • Step 1: Replace scripts/release/release.sh

#!/usr/bin/env bash
# TestFlight release, both platforms. Usage: scripts/release/release.sh (no argument)
# - bumps Sim_Const.BUILD by 1 and writes it into all four presets (scripts/bump-build.sh);
# the store build number IS BUILD (spec 2026-09-28-build-channels-pipeline-design.md §2)
# - dumps the remote-config manifest, builds + uploads iOS then tvOS, waits for processing
# - tags the built commit store-<N> and pushes the tag (never with NO_UPLOAD=1)
# - EXTERNAL=1 also attaches N to the external groups and submits beta review.
# Runs only from main or release/* with a clean tree. Commit the bump yourself afterwards
# (docs(release): build N). App Store (not beta) submission is Chris's.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd); cd "$ROOT"
[ $# -eq 0 ] || { echo "release.sh takes no argument any more: the store build number is Sim_Const.BUILD+1"; exit 2; }
git diff --quiet && git diff --cached --quiet || { echo "working tree dirty; commit first"; exit 1; }
BR=$(git rev-parse --abbrev-ref HEAD)
case "$BR" in main|release/*) ;; *) echo "release.sh runs only from main or release/* (on '$BR')"; exit 1 ;; esac
N=$(bash scripts/bump-build.sh | tail -1)
echo "==> store build $N (Sim_Const.BUILD) on $BR"
godot --headless --path . -s res://tools/remote_config/dump_manifest.gd
git add remote-config/manifest/
bash scripts/release/build-ios.sh
bash scripts/release/build-tvos.sh
[ -z "${NO_UPLOAD:-}" ] || { echo "NO_UPLOAD set: not uploading, not tagging"; exit 0; }
. scripts/release/env.sh
python3 scripts/release/asc.py "$([ -n "${EXTERNAL:-}" ] && echo beta || echo wait)" "$N"
# Tag what was built. The bump itself is still uncommitted here, so commit it first; the tag must
# point at a commit whose BUILD == N.
git add sim/constants.gd platform/tvos/export_presets.cfg remote-config/manifest/
git commit -q -m "chore: BUILD $N = store build $N" && git tag -a "store-$N" -m "Store build $N ($BR)" && git push -q origin "$BR" "store-$N"
echo "==> tagged + pushed store-$N"
  • Step 2: Check the guards without building

Run each and check the message and exit code:

  • bash scripts/release/release.sh 207: prints “takes no argument”, exit 2.

  • git switch -c tmp-guard && bash scripts/release/release.sh; git switch main && git branch -D tmp-guard: prints “runs only from main or release/*”, exit 1.

  • touch x.tmp && git add x.tmp && bash scripts/release/release.sh; git rm -q --cached x.tmp && rm x.tmp: prints “working tree dirty”, exit 1.

  • Step 3: Update bh-appstore-release SKILL.md

In .claude/skills/bh-appstore-release/SKILL.md:

  • Replace the blockquote starting > **Scripted since 2026-09-27: with: > **Scripted: scripts/release/release.sh(no argument, since 2026-09-28) runs steps 0-3 end to end.** It bumpsSim_Const.BUILDviascripts/bump-build.sh(store build number == BUILD), builds + uploads both platforms, waits for processing, then commits the bump and tags + pushesstore-N. Runs only from mainorrelease/*. Internal testers get every build; external groups + beta review only with EXTERNAL=1or a laterasc.py beta N. Hotfix: git switch -c release/ store-, fix, run release.sh there, merge back to main.
  • In §0 “Find the next build number”, replace the paragraph starting Bump \application/version`with:Don’t bump by hand: `scripts/bump-build.sh` is the only writer (all four presets + BUILD, kept in lockstep by tests/test_release_presets.gd).`

(.claude/ is gitignored: this edit is local, not committed.)

  • Step 4: Commit
Terminal window
git add scripts/release/release.sh
git commit -m "feat(release): release.sh takes no arg, store build = BUILD, tags store-N"

Files:

  • Create: scripts/dev/install-dev.sh
  • Create: scripts/dev/make-dev-icon.sh
  • Modify: .claude/skills/bh-deploy/SKILL.md (§C and §C2 command blocks, local-only)

Interfaces:

  • Consumes: the tvOS-Dev/iOS-Dev presets and the existing signed shells platform/tvos/build/ and platform/tvos/build-ios/.
  • Produces: bash scripts/dev/install-dev.sh <atv|iphone|ipad> [--no-bump]. It bumps BUILD (unless --no-bump), exports the -Dev pack, builds with bundle id digital.lumara.bulletheaven.dev + name Dark Cosmos Dev (+ AppIcon-Dev on iOS), installs and launches.

Device UUIDs (from bh-deploy): atv 8C229574-499D-51EE-9B4F-87DEB2865DB3, iphone 12FDF0CC-72E2-581B-B306-714C6E2275AC, ipad 4A2B411C-53F7-5BB3-AA08-8B1467E9142B.

  • Step 1: Write scripts/dev/make-dev-icon.sh
#!/usr/bin/env bash
# Badge every PNG of the iOS shell's AppIcon set with a DEV band -> AppIcon-Dev.appiconset.
# The shell (build-ios/) is gitignored and regenerated by full exports, so install-dev.sh calls this
# every time (cheap, idempotent). tvOS keeps the store icon (layered brand assets; name-only there).
set -euo pipefail
S=${1:?usage: make-dev-icon.sh <path to Images.xcassets>}
src="$S/AppIcon.appiconset"; dst="$S/AppIcon-Dev.appiconset"
[ -d "$src" ] || { echo "::error:: no $src" >&2; exit 1; }
rm -rf "$dst"; cp -R "$src" "$dst"
for f in "$dst"/*.png; do
h=$(magick identify -format '%h' "$f")
magick "$f" \( -size "$(magick identify -format '%w' "$f")x$((h/4))" xc:'#ff7a00' \) \
-gravity south -composite \
-fill white -font Helvetica-Bold -pointsize $((h/6)) -annotate +0+$((h/28)) DEV "$f"
done
echo "dev icon: $dst ($(ls "$dst"/*.png | wc -l | tr -d ' ') images)"
  • Step 2: Write scripts/dev/install-dev.sh
#!/usr/bin/env bash
# Build + install "Dark Cosmos Dev" (digital.lumara.bulletheaven.dev) next to the store/TestFlight
# app (spec 2026-09-28-build-channels-pipeline-design.md §1). Reuses the signed Xcode shells; the dev
# identity is applied as xcodebuild overrides, so the shells' pbxproj keeps the store id for release.sh.
# Usage: scripts/dev/install-dev.sh <atv|iphone|ipad> [--no-bump]
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd); cd "$ROOT"
DEV_ID=digital.lumara.bulletheaven.dev
case "${1:-}" in
atv) DEV=8C229574-499D-51EE-9B4F-87DEB2865DB3; KIND=tvos ;;
iphone) DEV=12FDF0CC-72E2-581B-B306-714C6E2275AC; KIND=ios ;;
ipad) DEV=4A2B411C-53F7-5BB3-AA08-8B1467E9142B; KIND=ios ;;
*) echo "usage: install-dev.sh <atv|iphone|ipad> [--no-bump]"; exit 2 ;;
esac
[ "${2:-}" = "--no-bump" ] || echo "BUILD -> $(bash scripts/bump-build.sh | tail -1)"
N=$(grep -oE '^const BUILD: int = [0-9]+' sim/constants.gd | grep -oE '[0-9]+$')
secrets >/dev/null 2>&1 || . "$HOME/.sec""rets" >/dev/null 2>&1
AUTH=(-allowProvisioningUpdates -authenticationKeyPath "$HOME/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8"
-authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID")
OVR=(PRODUCT_BUNDLE_IDENTIFIER=$DEV_ID INFOPLIST_KEY_CFBundleDisplayName="Dark Cosmos Dev"
CURRENT_PROJECT_VERSION=$N CODE_SIGN_IDENTITY="Apple Development")
cd platform/tvos
if [ "$KIND" = tvos ]; then
~/Claude/godot-tvos/godot/bin/godot.macos.editor.arm64 --headless --path . --export-pack "tvOS-Dev" build/BulletHeaven.pck
git checkout -- project.godot 2>/dev/null || true
cd build
DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer xcodebuild -scheme BulletHeaven -configuration Release \
-destination "id=$DEV" -derivedDataPath ./DerivedData-dev "${AUTH[@]}" "${OVR[@]}" build > dev-build.log 2>&1 \
|| { tail -30 dev-build.log; exit 1; }
APP=DerivedData-dev/Build/Products/Release-appletvos/BulletHeaven.app
else
/opt/homebrew/bin/godot --path . --rendering-method mobile --export-pack "iOS-Dev" build-ios/BulletHeaven.pck
git checkout -- project.godot 2>/dev/null || true
bash "$ROOT/scripts/dev/make-dev-icon.sh" build-ios/BulletHeaven/Images.xcassets
cd build-ios
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -scheme BulletHeaven -configuration Release \
-destination "id=$DEV" -derivedDataPath ./DerivedData-dev "${AUTH[@]}" "${OVR[@]}" \
ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Dev build > dev-build.log 2>&1 \
|| { tail -30 dev-build.log; exit 1; }
APP=DerivedData-dev/Build/Products/Release-iphoneos/BulletHeaven.app
fi
id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Info.plist")
[ "$id" = "$DEV_ID" ] || { echo "::error:: built app is '$id', not $DEV_ID: refusing to install over the store app"; exit 1; }
xcrun devicectl device install app --device "$DEV" "$APP"
xcrun devicectl device process launch --device "$DEV" "$DEV_ID" || echo "(launch failed: benign if the device is asleep)"
echo "==> Dark Cosmos Dev BUILD $N installed on $1"

chmod +x scripts/dev/*.sh

The bundle-id check before install is the guard from spec §Error handling: a dev build can never replace the store app.

  • Step 3: Verify on a real device (needs Chris’s OK on the day; devices must be awake)

Run: bash scripts/dev/install-dev.sh iphone Expected: “Dark Cosmos Dev BUILD 207 installed on iphone”. On the iPhone, Dark Cosmos (TestFlight/App Store) AND Dark Cosmos Dev (orange DEV badge) are both on the home screen. The Dev app starts with a fresh save (the tutorial shows), and the store app’s progress is unchanged. If signing fails with “No profiles for ‘digital.lumara.bulletheaven.dev’”: automatic signing didn’t register the App ID. Register it with python3 scripts/release/asc.py style JWT + POST /v1/bundleIds ({"data":{"type":"bundleIds","attributes":{"identifier":"digital.lumara.bulletheaven.dev","name":"Dark Cosmos Dev","platform":"UNIVERSAL"}}}), then re-run with --no-bump. Do not fall back to the store id. Then: bash scripts/dev/install-dev.sh atv --no-bump. Same checks on the Apple TV (store icon, name “Dark Cosmos Dev” when focused).

  • Step 4: Point bh-deploy at the script

In .claude/skills/bh-deploy/SKILL.md, at the top of §C and §C2 add: **Default path (2026-09-28): \bash scripts/dev/install-dev.sh <atv|iphone|ipad>`** installs the separate Dark Cosmos Dev app (`digital.lumara.bulletheaven.dev`) next to the store app, bumping BUILD via `scripts/bump-build.sh`. The manual commands below are the reference for what it does. They install under the STORE id and overwrite the store app, so only use them deliberately.`

  • Step 5: Commit (the BUILD bump from Step 3 goes in with it)
Terminal window
git add scripts/dev/ sim/constants.gd platform/tvos/export_presets.cfg
git commit -m "feat(dev): Dark Cosmos Dev app (separate bundle id) via scripts/dev/install-dev.sh"

Files:

  • Create: net/build_channel.gd
  • Test: tests/test_build_channel.gd

Interfaces:

  • Produces: BuildChannel.classify(is_dev: bool, is_web: bool, sandbox_receipt: bool, prod_receipt: bool) -> String (pure).
  • Produces: BuildChannel.current() -> String (cached per process) and BuildChannel.probe() -> String. The probe is a diagnostic: "sandbox", "prod" or "none", plus the path that matched, e.g. "sandbox@container". Tasks 5 and 6 send it as channel_probe so the first TestFlight build proves where the receipt lives.

Receipt locations checked, in order, for each of sandboxReceipt / receipt:

  1. OS.get_executable_path().get_base_dir() + "/StoreKit/" (app bundle), label bundle
  2. OS.get_user_data_dir().get_base_dir() + "/StoreKit/" (data container; user:// is <container>/Documents on iOS/tvOS), label container

[Guessing] which one iOS 27 uses; the probe settles it (Task 9).

  • Step 1: Write the failing test tests/test_build_channel.gd
extends GutTest
# BuildChannel.classify is the pure decision; current() just feeds it OS facts.
# Rule: dev wins, then web, then a sandbox receipt = testflight; ANYTHING else = appstore, so a
# detection failure can never label real players as test traffic.
func test_dev_build_is_dev_whatever_the_receipt() -> void:
assert_eq(BuildChannel.classify(true, false, true, false), "dev")
assert_eq(BuildChannel.classify(true, false, false, true), "dev")
func test_web_is_web() -> void:
assert_eq(BuildChannel.classify(false, true, false, false), "web")
func test_sandbox_receipt_is_testflight() -> void:
assert_eq(BuildChannel.classify(false, false, true, false), "testflight")
func test_prod_receipt_is_appstore() -> void:
assert_eq(BuildChannel.classify(false, false, false, true), "appstore")
func test_no_receipt_defaults_to_appstore() -> void:
assert_eq(BuildChannel.classify(false, false, false, false), "appstore")
func test_both_receipts_prefers_appstore() -> void:
# Ambiguous = don't mark real players as testers.
assert_eq(BuildChannel.classify(false, false, true, true), "appstore")
func test_current_in_editor_is_dev() -> void:
assert_eq(BuildChannel.current(), "dev", "GUT runs in the editor binary, which BuildConfig treats as dev")
  • Step 2: Run it and confirm it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit Expected: FAIL / parse error, “BuildChannel” not declared.

  • Step 3: Write net/build_channel.gd
class_name BuildChannel
extends RefCounted
# Which kind of build is running: "dev" | "web" | "testflight" | "appstore"
# (spec docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md §4). Render/net side
# only: uses OS + FileAccess, never referenced from /sim. Sent with every telemetry / feedback /
# remote-config request. ANY detection failure = "appstore", so real players are never mislabelled
# as testers. TestFlight vs App Store is the StoreKit receipt's file name: TestFlight installs carry
# "sandboxReceipt", App Store installs "receipt".
static var _cached: String = ""
static var _probe: String = ""
static func current() -> String:
if _cached == "":
var found := _find_receipts()
_cached = classify(BuildConfig.dev_tools(), OS.has_feature("web"), found.sandbox, found.prod)
return _cached
# Diagnostic for the first TestFlight/App Store builds: which receipt was found, and where.
static func probe() -> String:
if _probe == "":
_find_receipts()
return _probe
static func classify(is_dev: bool, is_web: bool, sandbox_receipt: bool, prod_receipt: bool) -> String:
if is_dev:
return "dev"
if is_web:
return "web"
if sandbox_receipt and not prod_receipt:
return "testflight"
return "appstore"
static func _find_receipts() -> Dictionary:
var out := {"sandbox": false, "prod": false}
_probe = "none"
if not (OS.has_feature("ios") or OS.has_feature("tvos")):
return out
var dirs := {
"bundle": OS.get_executable_path().get_base_dir() + "/StoreKit/",
"container": OS.get_user_data_dir().get_base_dir() + "/StoreKit/",
}
for where in dirs:
if FileAccess.file_exists(dirs[where] + "sandboxReceipt"):
out.sandbox = true
_probe = "sandbox@" + where
if FileAccess.file_exists(dirs[where] + "receipt"):
out.prod = true
_probe = "prod@" + where
return out

Check: OS.has_feature("tvos") is the feature tag the godot-tvos fork sets. Confirm with git grep -n 'has_feature("tvos")' -- '*.gd'. If nothing uses it, use the tag this repo already uses for tvOS detection (git grep -n "has_feature(" -- '*.gd' | grep -i tv).

  • Step 4: Run it and confirm it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit Expected: 7/7 pass.

  • Step 5: Commit
Terminal window
git add net/build_channel.gd tests/test_build_channel.gd
git commit -m "feat(net): BuildChannel (dev/web/testflight/appstore, default appstore)"

Files:

  • Modify: net/telemetry.gd (sample body ~line 128, hitch body ~line 109)
  • Modify: net/gameplay_telemetry.gd (run body ~line 56, feedback_body ~line 96)
  • Modify: net/dash_encounter_telemetry.gd (~line 103)
  • Modify: net/remote_config.gd:61
  • Test: add to tests/test_build_channel.gd

Interfaces:

  • Consumes: BuildChannel.current(), BuildChannel.probe() (Task 4).
  • Produces: JSON bodies gain "channel": String and "channel_probe": String. GET /config gains &channel=<c>.

Recipe (the same in each file). In each body dictionary, directly after the existing "build": Sim_Const.BUILD, entry, add:

"channel": BuildChannel.current(), "channel_probe": BuildChannel.probe(),

Verified body sites: net/telemetry.gd (var body := {"build": Sim_Const.BUILD, "session": _session, "dev": _device, "quality": quality, "hitches": _hitch_queue} at ~109, and the multi-line sample body at ~128), net/gameplay_telemetry.gd (the run body at ~56 and feedback_body()’s return {"build": Sim_Const.BUILD, ... at ~96), net/dash_encounter_telemetry.gd (~103). For the single-line dicts, insert the two keys after "build": Sim_Const.BUILD, on the same line.

  • Step 1: Write the failing test (append to tests/test_build_channel.gd)
func test_feedback_body_carries_channel() -> void:
var body: Dictionary = GameplayTelemetry.feedback_body("bug", "x", "s", "dev", null, "touch")
assert_eq(body.get("channel"), "dev")
assert_true(body.has("channel_probe"))

Before running, check feedback_body’s real signature: grep -n "func feedback_body" net/gameplay_telemetry.gd. Adjust the call’s arguments to match it exactly (it’s static; if it isn’t, construct the node with GameplayTelemetry.new() and free() it after).

  • Step 2: Run it and confirm it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit Expected: test_feedback_body_carries_channel FAILS (channel is null).

  • Step 3: Apply the recipe to all five bodies, then change net/remote_config.gd:61 to
if _http.request("%s?build=%d&channel=%s&install=%s" % [ENDPOINT, _build, BuildChannel.current(), _install_id.uri_encode()]) != OK:
  • Step 4: Run the test, then the full gate

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit, then bash scripts/gate.sh. Expected: 8/8, then GATE PASS. The existing telemetry/remote-config tests must pass unchanged (the worker ignores unknown fields until Task 6).

  • Step 5: Commit
Terminal window
git add net/ tests/test_build_channel.gd
git commit -m "feat(net): send channel + channel_probe with telemetry, feedback and config fetch"

Task 6: Worker stores channel and serves per-channel config

Section titled “Task 6: Worker stores channel and serves per-channel config”

Files:

  • Modify: telemetry/src/worker.js (resolveConfig ~37, /config route ~59, INSERTs at ~73 samples, ~95 hitches, ~116 runs, ~148 encounters, ~176 feedback)
  • Modify: telemetry/schema.sql (add channel TEXT and channel_probe TEXT to the five CREATE TABLEs)
  • Create: telemetry/test/channel.test.mjs

Interfaces:

  • Produces: resolveConfig(kv, build, channel = ""). Lookup order: build-N@channel → build-N → the nearest older build, where for each older build build-M@channel beats build-M. The @-suffixed keys are ignored when channel is empty.

  • Produces: rows in all five tables carry channel/channel_probe (NULL for old clients).

  • Step 1: Write the failing test telemetry/test/channel.test.mjs

import { test } from "node:test";
import assert from "node:assert/strict";
import worker, { resolveConfig } from "../src/worker.js";
const kv = (m) => ({
get: async (k) => m[k] ?? null,
list: async ({ prefix }) => ({ keys: Object.keys(m).filter((k) => k.startsWith(prefix)).map((name) => ({ name })) }),
});
test("channel-specific patch beats the plain one for that channel only", async () => {
const m = { "build-207": '{"config_id":"all"}', "build-207@testflight": '{"config_id":"tf"}' };
assert.equal(await resolveConfig(kv(m), 207, "testflight"), '{"config_id":"tf"}');
assert.equal(await resolveConfig(kv(m), 207, "appstore"), '{"config_id":"all"}');
assert.equal(await resolveConfig(kv(m), 207), '{"config_id":"all"}');
});
test("older-build fallback honours channel too, and never leaks a channel patch to others", async () => {
const m = { "build-205@testflight": '{"config_id":"tf205"}', "build-204": '{"config_id":"all204"}' };
assert.equal(await resolveConfig(kv(m), 207, "testflight"), '{"config_id":"tf205"}');
assert.equal(await resolveConfig(kv(m), 207, "appstore"), '{"config_id":"all204"}');
});
test("/config passes ?channel through", async () => {
const res = await worker.fetch(new Request("https://x/config?build=207&channel=testflight"),
{ CONFIG: kv({ "build-207@testflight": '{"config_id":"tf"}' }) });
assert.equal(await res.text(), '{"config_id":"tf"}');
});
test("/feedback stores channel", async () => {
let bound = null;
const DB = { prepare: (sql) => ({ bind: (...a) => { if (sql.includes("INSERT INTO feedback")) bound = { sql, a }; return { run: async () => ({}), first: async () => ({ n: 0 }) }; } }) };
await worker.fetch(new Request("https://x/feedback", { method: "POST", body: JSON.stringify({ build: 207, session: "s", kind: "bug", note: "n", channel: "testflight", channel_probe: "sandbox@container" }) }), { DB });
assert.ok(bound, "feedback INSERT ran");
assert.match(bound.sql, /channel,channel_probe/);
assert.ok(bound.a.includes("testflight") && bound.a.includes("sandbox@container"));
});
  • Step 2: Run it and confirm it fails

Run: cd telemetry && npm test Expected: the 4 new tests fail and the existing tests pass. If the /feedback mock doesn’t match how the route calls D1 (e.g. it uses .all()), read the route at ~line 165-180 and extend the mock’s returned object with that method. Don’t change the route to suit the mock.

  • Step 3: Implement resolveConfig with channel
export async function resolveConfig(kv, build, channel = "") {
if (!kv || !(build > 0)) return "{}";
const ch = /^[a-z]{1,12}$/.test(channel) ? channel : "";
const pick = async (n) => (ch && (await kv.get(`build-${n}@${ch}`))) || (await kv.get(`build-${n}`));
const exact = await pick(build);
if (exact) return exact;
const { keys } = await kv.list({ prefix: "build-" });
let best = -1;
for (const k of keys) {
const [num, kc] = k.name.slice(6).split("@");
const n = parseInt(num, 10);
if (kc && kc !== ch) continue;
if (Number.isFinite(n) && n < build && n > best) best = n;
}
return best < 0 ? "{}" : ((await pick(best)) || "{}");
}

and in the /config route: body = await resolveConfig(env.CONFIG, intt(url.searchParams.get("build")), str(url.searchParams.get("channel"), 12));

  • Step 4: Add the columns to the five INSERTs

Recipe per INSERT: append ,channel,channel_probe to the column list, append ,?,? to VALUES, and append str(X.channel, 12) || null, str(X.channel_probe, 40) || null to .bind(...), where X is that route’s parsed-body variable (s for /sample; read each route to get its name).

In telemetry/schema.sql, add channel TEXT, channel_probe TEXT before the closing ) of each of the five CREATE TABLEs.

  • Step 5: Run the tests and confirm they pass

Run: cd telemetry && npm test Expected: all pass (old + 4 new).

  • Step 6: Commit
Terminal window
git add telemetry/
git commit -m "feat(telemetry): store channel/channel_probe; per-channel remote config (build-N@channel)"
  • Step 7: Migrate D1, then deploy (Chris confirms on the day; worker before any client ships)

Migration first. The new INSERTs fail on a table without the columns.

Terminal window
cd telemetry && secrets
for t in samples hitches runs encounters feedback; do
CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc \
npx --yes wrangler@latest d1 execute bullet-heaven-telemetry --remote \
--command "ALTER TABLE $t ADD COLUMN channel TEXT; ALTER TABLE $t ADD COLUMN channel_probe TEXT;"
done
CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx --yes wrangler@latest deploy

Verify: curl -s "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/config?build=206&channel=appstore" returns the same body as without &channel=. After a feedback POST from a dev build, SELECT channel, channel_probe FROM feedback ORDER BY ts DESC LIMIT 1 shows dev.


Files:

  • Modify: scripts/publish-config.mjs (lines 33-47)
  • Modify: scripts/test/publish-config.test.mjs

Interfaces:

  • Produces: node scripts/publish-config.mjs <N> [--channel <testflight|appstore|dev|web>] [--dry-run] publishes to KV key build-<N> or build-<N>@<channel>, and exports kvKey(n, channel) for the test.

  • Step 1: Write the failing test (append to scripts/test/publish-config.test.mjs, using its existing import style)

import { kvKey } from "../publish-config.mjs";
test("kvKey: plain and per-channel", () => {
assert.equal(kvKey(207), "build-207");
assert.equal(kvKey(207, "testflight"), "build-207@testflight");
assert.throws(() => kvKey(207, "beta"), /channel/);
});
  • Step 2: Run it and confirm it fails. node --test scripts/test/ fails: kvKey is not exported.

  • Step 3: Implement

Add near the top of scripts/publish-config.mjs:

const CHANNELS = ["dev", "web", "testflight", "appstore"];
export function kvKey(n, channel) {
if (channel === undefined) return `build-${n}`;
if (!CHANNELS.includes(channel)) throw new Error(`unknown channel '${channel}' (one of ${CHANNELS.join(", ")})`);
return `build-${n}@${channel}`;
}

In the CLI block: parse const ci = process.argv.indexOf("--channel"); const channel = ci > 0 ? process.argv[ci + 1] : undefined;. Replace the literal `build-${n}` in the wrangler kv key put args and the final console.log with kvKey(n, channel). Update the usage strings to include [--channel <c>].

  • Step 4: Run it and confirm it passes. node --test scripts/test/: all pass.

  • Step 5: Commit

Terminal window
git add scripts/publish-config.mjs scripts/test/publish-config.test.mjs
git commit -m "feat(remote-config): publish-config --channel (KV build-N@channel)"

Task 8: DevFlags replaces the dev-only consts

Section titled “Task 8: DevFlags replaces the dev-only consts”

Files:

  • Create: meta/dev_flags.gd
  • Test: tests/test_dev_flags.gd
  • Modify: main.gd (consts at 158, 167, 176, 203, 207; uses at 466, 475, 749, 906, 1198, 1326, 1329, 1347, 1351, 1734, 1744, 1756)
  • Modify: ui/debug_settings_panel.gd (_build_rows ~282; new _dev_flag_row)

Interfaces:

  • Produces: DevFlags.perf_bench: bool, DevFlags.auto_showcase: bool, DevFlags.record_replay: bool (static vars, default false). DevFlags.load_for(is_dev: bool, d: Dictionary) -> void (pure). DevFlags.boot() -> void (reads user://dev_flags.json via LocalJson.read in dev builds, otherwise forces all false). DevFlags.save() -> void.

Decisions:

  • SHIP3D_MEASURE (disabled 2026-07-01, broken on Metal) and MEASURE_FULL_QUALITY (“measurement loop closed for the v0.1 launch”) are deleted with their branches, not ported.

  • ADAPTIVE_QUALITY, V01_LOCK_* and the tuning numbers (PERF_BENCH_DWELL_S etc.) stay consts.

  • Step 1: Write the failing test tests/test_dev_flags.gd

extends GutTest
func after_each() -> void:
DevFlags.load_for(false, {})
func test_defaults_are_all_false() -> void:
DevFlags.load_for(false, {})
assert_false(DevFlags.perf_bench)
assert_false(DevFlags.auto_showcase)
assert_false(DevFlags.record_replay)
func test_release_build_ignores_a_saved_file() -> void:
DevFlags.load_for(false, {"perf_bench": true, "auto_showcase": true, "record_replay": true})
assert_false(DevFlags.perf_bench or DevFlags.auto_showcase or DevFlags.record_replay,
"a non-dev build must force every dev flag off, whatever is on disk")
func test_dev_build_reads_the_saved_file() -> void:
DevFlags.load_for(true, {"perf_bench": true, "record_replay": true})
assert_true(DevFlags.perf_bench)
assert_false(DevFlags.auto_showcase)
assert_true(DevFlags.record_replay)
func test_garbage_values_are_false() -> void:
DevFlags.load_for(true, {"perf_bench": "yes", "auto_showcase": 1})
assert_false(DevFlags.perf_bench)
assert_false(DevFlags.auto_showcase)
func test_main_has_no_dev_mode_consts_left() -> void:
var src := FileAccess.get_file_as_string("res://main.gd")
for needle in ["const PERF_BENCH :=", "const AUTO_SHOWCASE :=", "const RECORD_REPLAY :=", "SHIP3D_MEASURE", "MEASURE_FULL_QUALITY"]:
assert_false(src.contains(needle), "main.gd must not contain '%s': use DevFlags / deleted (spec §5)" % needle)
  • Step 2: Run it and confirm it fails. -gselect=test_dev_flags fails: DevFlags not declared.

  • Step 3: Write meta/dev_flags.gd

class_name DevFlags
extends RefCounted
# Dev-only run modes (spec docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md §5).
# They used to be `const X := false` in main.gd, flipped by editing code, so one could ship `true`.
# Now: runtime flags set from Debug Settings (dev builds only), saved to user://dev_flags.json, and
# forced false in every non-dev build regardless of the file. They take effect on the NEXT launch
# (perf_bench / auto_showcase skip the menu at boot). Never referenced from /sim.
const FILE := "dev_flags.json"
static var perf_bench: bool = false # autopilot sweeps quality tiers (main.PERF_BENCH_* tuning)
static var auto_showcase: bool = false # hands-free invulnerable autopilot showcase
static var record_replay: bool = false # record input replays to user://replays/
static func boot() -> void:
var dev := BuildConfig.dev_tools()
load_for(dev, LocalJson.read(FILE) if dev else {})
static func load_for(is_dev: bool, d: Dictionary) -> void:
perf_bench = is_dev and d.get("perf_bench", false) is bool and d.get("perf_bench", false)
auto_showcase = is_dev and d.get("auto_showcase", false) is bool and d.get("auto_showcase", false)
record_replay = is_dev and d.get("record_replay", false) is bool and d.get("record_replay", false)
static func save() -> void:
if BuildConfig.dev_tools():
LocalJson.write(FILE, {"perf_bench": perf_bench, "auto_showcase": auto_showcase, "record_replay": record_replay})
  • Step 4: Migrate main.gd
  1. Delete the const lines for MEASURE_FULL_QUALITY (158 and its 4 comment lines above), PERF_BENCH (167 only; keep PERF_BENCH_DWELL_S/_MAX_LEVEL/_RUN_S), AUTO_SHOWCASE (176; keep SHOWCASE_WARP_S/SHOWCASE_LOOK_S), SHIP3D_MEASURE (203 and its comment block 196-202), RECORD_REPLAY (207; update the comment above it to say “DevFlags.record_replay”).
  2. Replace every whole-word use: PERF_BENCH → DevFlags.perf_bench, AUTO_SHOWCASE → DevFlags.auto_showcase, RECORD_REPLAY → DevFlags.record_replay. Use \b word boundaries so PERF_BENCH_DWELL_S etc. are untouched. Verified sites: 466, 475, 749, 906, 1326, 1329, 1734, 1744.
  3. Line 1347 if not ADAPTIVE_QUALITY or MEASURE_FULL_QUALITY: → if not ADAPTIVE_QUALITY:. Delete the if MEASURE_FULL_QUALITY: blocks at ~1351 and ~1756. Then run grep -n measure_label main.gd: if the only remaining references are its declaration and creation, delete those too.
  4. Delete the if SHIP3D_MEASURE: block at ~1198. Then run grep -n "ship3d\b\|Ship3DRenderer" main.gd: if ship3d is now only declared (line 9) and never read, delete line 9. Leave the Ship3DRenderer class file alone (the cleanup audit owns it).
  5. At the very top of func _ready() -> void: (line 326) add DevFlags.boot() as the first statement.
  • Step 5: Debug Settings rows (dev builds only)

In ui/debug_settings_panel.gd _build_rows(), after the three _static_toggle_row lines, add:

if BuildConfig.dev_tools():
_rows.append(_dev_flag_row("Dev: perf bench (next launch)", "perf_bench"))
_rows.append(_dev_flag_row("Dev: auto showcase (next launch)", "auto_showcase"))
_rows.append(_dev_flag_row("Dev: record replays", "record_replay"))

and add, next to _static_toggle_row:

func _dev_flag_row(label: String, field: String) -> Dictionary:
return {
"label": label,
"is_graphics": false,
"get_state": func() -> bool: return bool(DevFlags.get(field)),
"set_state": func(v: bool) -> void:
DevFlags.set(field, v)
DevFlags.save(),
}

Check first: static vars are reachable through DevFlags.get(field) / DevFlags.set(...) on the class in Godot 4.7. If not (it returns null), switch on field the way _touch_static does at line 470.

  • Step 6: Run the tests and the full gate

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_dev_flags -gexit (5/5), then also -gselect=test_debug_settings_panel (a row-count assertion may need its expected count raised by 3 in dev/editor context; that’s the only acceptable test edit), then bash scripts/gate.sh. Expected: GATE PASS. The determinism tests are unchanged (render-side only).

  • Step 7: Commit
Terminal window
git add meta/dev_flags.gd tests/test_dev_flags.gd main.gd ui/debug_settings_panel.gd tests/test_debug_settings_panel.gd
git commit -m "refactor(main): dev-only modes -> DevFlags (dev builds only); drop dead SHIP3D/MEASURE_FULL_QUALITY"

Task 9: Docs, the queue, and the first-TestFlight check

Section titled “Task 9: Docs, the queue, and the first-TestFlight check”

Files:

  • Modify: docs/architecture/dev-tools.md (add a “Build channels” section)

  • Modify: docs/architecture/remote-config.md (per-channel keys)

  • Modify: docs/RELEASE-1.0.1.md (tick the pipeline item)

  • Modify: docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md (Amendments, already added with this plan)

  • Step 1: docs/architecture/dev-tools.md. Append:

## Build channels (2026-09-28)
Spec: `docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md`.
- **Dev app:** `bash scripts/dev/install-dev.sh <atv|iphone|ipad>` installs **Dark Cosmos Dev**
(`digital.lumara.bulletheaven.dev`, own saves, DEV-badged icon on iOS) next to the store app.
- **One build number:** `scripts/bump-build.sh` is the only writer; store build == `Sim_Const.BUILD`;
`tests/test_release_presets.gd` enforces it. `release.sh` tags every upload `store-N`.
- **Channel:** `BuildChannel.current()` (`dev`/`web`/`testflight`/`appstore`, failure → `appstore`)
rides on every telemetry/feedback row (`channel`, `channel_probe` columns) and `GET /config`.
Filter real players with `WHERE channel='appstore'`; rows before BUILD 207 are NULL.
- **Dev modes:** `DevFlags` (Debug Settings → "Dev: …", dev builds only, next launch).
  • Step 2: docs/architecture/remote-config.md. Add under the publishing section: Per-channel patches: \node scripts/publish-config.mjs –channel testflight` writes KV `build-N@testflight`, served only to that channel (lookup: `build-N@ch` → `build-N` → nearest older, same order). Use it to trial a balance patch on TestFlight before App Store players get it.`

  • Step 3: Tick the pipeline line in docs/RELEASE-1.0.1.md → ✅ DONE (<commit>), keeping the numbering note.

  • Step 4: Commit + push

Terminal window
git add docs/
git commit -m "docs: build channels (dev app, one build number, channel field, DevFlags)"
git push origin main
  • Step 5: The first-TestFlight check (when 1.0.1’s first build goes up)

After release.sh uploads BUILD ≥ 207 and Chris opens the TestFlight build on the iPhone and the Apple TV:

SELECT dev, channel, channel_probe, COUNT(*) FROM samples WHERE build >= 207 GROUP BY 1,2,3;

Expected: testflight / sandbox@<bundle|container> for Chris’s TestFlight devices, and dev for the Dev app. If TestFlight rows show appstore / none: neither receipt path exists on iOS/tvOS 27. Add it to docs/RELEASE-1.0.1.md as a bug. The fix is a native check (AppTransaction.shared.environment via a Swift shim in the shells, written into a file BuildChannel reads), spec’d separately. Until then, TestFlight traffic counts as appstore, the safe direction.