diff --git a/docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md b/docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md new file mode 100644 index 000000000..28d067aed --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-healthchat-appstore-release-design.md @@ -0,0 +1,445 @@ +# HealthChat (element-x-ios) — App Store release capability + +**Date:** 2026-07-15 +**Branch:** `release/healthchat` +**Status:** Design approved; revised after adversarial review (26 issues, 0 rejected) + +## Context + +`Novaloop-AG/element-x-ios` is the HealthChat rebrand of Element X. The rebrand landed in `01dc703b4` +(branding, fixed homeserver, variant seam). What it cannot do is ship: the repo has **no release machinery +of any kind** — no `fastlane/`, no `Gemfile`, and none of its 17 GitHub workflows build or upload anything. + +The legacy fork `Novaloop-AG/element-ios` ships HealthChat to the App Store today via a proven pipeline +(last green run 2026-04-15, version 1.4.1). This design brings equivalent capability to element-x-ios and +captures the process in a skill. + +Both apps use **the same bundle id** (`ch.healthchat.element`) and **the same Apple team** (`6G37K72W82`). +element-x therefore reaches the App Store as an **in-place update** of the existing HealthChat record. That +single fact drives most of this design — including the fact that the two apps can never coexist on a +device, which upstream's migration UX assumes they can. + +### Why upstream's own release path is unavailable + +element-hq ships element-x via Xcode Cloud. `ci_scripts/ci_post_clone.sh` branches on `$CI_WORKFLOW` and +calls `swift run pipeline update-foss-secrets` (`Tools/Sources/Commands/CI/ConfigureProduction.swift:8`). +`pipeline` lives only in the private `Enterprise` submodule (`.gitmodules` → `element-hq/element-ios-enterprise`; +`Enterprise/` is empty here and Novaloop cannot obtain it). Separately `ci_scripts/ci_post_xcodebuild.sh:13` +unconditionally uploads dSYMs to Element's Sentry org. Xcode Cloud is a dead end for this fork. + +## Decisions + +| # | Decision | Rationale | +|---|---|---| +| D1 | **In-place update** of the existing App Store record | Identity already aligns. `26.07.3` already clears the legacy's `1.4.1`, so no version bump is needed. | +| D2 | **Preserve** legacy sessions + E2EE keys | The migration code already ships; the fix is 3 scalars + 1 entitlement, all HealthChat's own provisionable ids. | +| D3 | **Port the legacy fastlane** (slim, purpose-built) | Xcode Cloud is blocked. The legacy pipeline is proven and reuses the identical team, cert and ASC key. | +| D4 | Migration fix ships **first, as its own PR** | It corrects a defect in `01dc703b4`. **Note: it is _not_ monotonic — see D9.** | +| D5 | Skill lives at `.agents/skills/releasing-healthchat/SKILL.md` | Reachable via the existing `.claude/skills -> ../.agents/skills` symlink. A new subdirectory cannot conflict on rebase. | +| D6 | Reuse the legacy's `HEALTHCHAT_*` secrets | Same team, cert and ASC key. **Reject fastlane match** — the legacy deliberately has none. | +| D7 | Keep the `healthchat-v*` tag namespace | Upstream owns `release/` (`ReleaseToGithub.swift:83-85`) and `nightly/*`. | +| D8 | **Create** `HEALTHCHAT_CHANGES.md` as the release-notes source | element-x's `CHANGES.md` is owned by upstream's `release-to-github`. The file does **not exist** in this repo — it must be created, not carried over. | +| D9 | Fix the `.requiresBackup` dead-end with a **small upstream edit** | Accepts the first-ever edit to an upstream logic file. Required for D4 to be safe — see Part 1. | + +--- + +## Part 1 — Classic migration fix (PR1, `pr-bugfix`) + +### The defect + +`01dc703b4` dropped `$(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)` from the entitlements with the +rationale "not provisionable here". That was correct *about Element's identifiers* — but the fix is to +**repoint them at HealthChat's own**, not to remove them. + +Left as-is, every existing user who updates is silently logged out and loses local encrypted history +unless they had key backup. `loadAccounts()` throws `invalidAppGroupIdentifier`, caught and logged only — +`AuthenticationService.swift:59-63`: *"No need to alert the user of the failure, just log it."* + +### Why the fix is small + +The plumbing already ships. `ElementX/SupportingFiles/target.yml:78-81` maps the values into `Info.plist`; +`InfoPlistReader.swift:28-31,129-143` reads them. `ClassicAppManager` reads exactly `accountIv`, +`accountAesKey`, `cryptoSDKStoreKey` (`ClassicAppManager.swift:31-35,61-68`) — and the legacy +`EncryptionKeyManager` writes exactly those three names, to service +`ch.healthchat.element.encryption-manager-service`, in access group +`$(AppIdentifierPrefix)ch.healthchat.element.keychain.shared` +(`element-ios/Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift:17,20-25,27`). + +Only the scalars are wrong: `app.yml:11-13` points them at Element's `group.im.vector` / `im.vector.app.*`, +and `healthchat.yml` does not override them. + +**Verified mechanics** (these were the design's load-bearing assumptions): +- **XcodeGen scalar precedence.** `healthchat.yml` is included after `app.yml` (`project.yml:71` vs `:61`) + and wins — proven by the already-generated pbxproj, where `APP_GROUP_IDENTIFIER = group.ch.healthchat` + and `BASE_BUNDLE_IDENTIFIER = ch.healthchat.element` beat app.yml's Element values. +- **No trailing-dot bug.** `$(AppIdentifierPrefix)` carries its own trailing dot, so the legacy resolves to + `6G37K72W82.ch.healthchat.element.keychain.shared`. `$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared` + is byte-equivalent and matches upstream's own convention (`app.yml:13`). + +### The change + +In `Variants/HealthChat/healthchat.yml`: + +```yaml +CLASSIC_APP_GROUP_IDENTIFIER: group.ch.healthchat +CLASSIC_APP_KEYCHAIN_SERVICE_IDENTIFIER: ch.healthchat.element.encryption-manager-service +CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER: "$(DEVELOPMENT_TEAM).ch.healthchat.element.keychain.shared" +``` + +In `ElementX/SupportingFiles/target.yml`, restore the classic keychain group to `keychain-access-groups` +and correct the misleading comment at `:132`: + +```yaml +keychain-access-groups: +- $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER) +# HealthChat: the legacy app's keychain group — ClassicAppManager reads the account + crypto store +# keys from here to migrate existing users. Same team prefix, so it is provisionable. +- $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER) +``` + +**Do not** re-add `$(CLASSIC_APP_GROUP_IDENTIFIER)` to `com.apple.security.application-groups`: it resolves +to `group.ch.healthchat`, already the value of `$(APP_GROUP_IDENTIFIER)`, so it would duplicate the entry. +Correct the comment at `:128` to say this rather than implying the group was dropped as un-provisionable. + +`CLASSIC_APP_DEEP_LINK_URL` needs no override — legacy HealthChat registers the `element` scheme +(`element-ios/Config/AppIdentifiers.xcconfig:22`) — but see the `.requiresBackup` fix below, which makes it +moot. + +**Finally: run `xcodegen` and commit both `ElementX.xcodeproj/project.pbxproj` and +`ElementX/SupportingFiles/ElementX.entitlements`.** Both are tracked and not ignored. Without this the +checked-in entitlements still lack the classic group and anyone building the branch gets the un-fixed, +silently-failing behaviour. + +### The `.requiresBackup` fix (D9) — why Part 1 is not monotonic + +`availableSecrets` maps `.withoutBackup` / `.unusedBackup` → `.requiresBackup` +(`ClassicAppManager.swift:82-85`). `login(classicAppAccount:)` routes that to +`state.bindings.showClassicAppBackupInstructions`, whose sheet's only button fires `.openClassicApp` → +`appMediator.open(element://open)` (`AuthenticationStartScreenViewModel.swift:106`). + +**That URL resolves to nothing.** element-x registers only `$(BASE_BUNDLE_IDENTIFIER)` and `matrix` +(`ElementX/SupportingFiles/target.yml:60-63`) — no `element` scheme — and the legacy app that *did* +register it has been replaced in place by D1's same-bundle-id update. Upstream's UX assumes Element Classic +(`im.vector.app`) and Element X (`io.element.elementx`) coexist as separate apps. HealthChat cannot. + +So making `loadAccounts()` succeed switches on a UI branch that is worse than the status quo for +`.requiresBackup` users: today they see a working login screen; after the fix they see a modal urging them +to open an app that no longer exists. The sheet is dismissible (`@Environment(\.dismiss)`) and `otherOptions` +(`AuthenticationClassicAppAccountView.swift:106`) is an escape hatch, so it is not a hard lockout — but it +is a knowingly broken call-to-action. + +**Fix:** in `AuthenticationStartScreenViewModel.login(classicAppAccount:)`, treat `.requiresBackup` the same +way `.unavailable` is already treated — skip the sheet and fall through to +`configureAccountProvider(account.serverName, loginHint: "mxid:\(account.userID)", fallbackHomeserverURL:)`. +Those users sign into their own account and re-verify the device, losing only history that by definition has +no backup. + +This is the **first edit to an upstream logic file** in this fork, ending the rebrand's "no upstream logic +edits" property. It is ~3 lines in a method upstream rarely touches. Record it in the skill's divergence +section so a future rebase does not silently revert it. + +### Precondition for D2 (verified, but confirm on device) + +`AuthenticationStartScreenViewModel:46` gates the whole classic flow on +`appSettings.accountProviders.contains(serverName)`, where `serverName` is derived from the **MXID domain** +(`ClassicAppAccountManager.swift:107-111`), not the homeserver URL. HealthChat pins +`accountProviders: ["healthchat.ch"]`. + +`curl https://healthchat.ch/.well-known/matrix/server` → `{"m.server": "matrix.healthchat.ch:8448"}`. This is +*server-name delegation*: the server is **named** `healthchat.ch` and merely reachable at +`matrix.healthchat.ch:8448`. Legacy MXIDs are therefore `@user:healthchat.ch`, the gate passes, and D2 holds. +Confirm against a real legacy MXID during device testing. + +--- + +## Part 2 — Release pipeline (PR2, `pr-build`) + +### Design note: this fork owns `fastlane/` outright + +Upstream element-x-ios has **no `fastlane/` directory**. The legacy skill devotes a whole section to rebase +divergence in `fastlane/.env.default` and `fastlane/Fastfile` precisely because those are upstream-owned in +element-ios. Here that class of conflict does not exist — which is why this is a slim purpose-built Fastfile +(~120 lines), not a copy of Vector's ~600-line multi-variant one. + +**Dropped:** cocoapods (element-x is SPM), diawi, sentry (HealthChat has no Sentry), and the `adhoc` / +`alpha` / `setup_app_variant` lanes. Siri-intents and broadcast-upload have no counterpart target here. + +### Files + +| File | Purpose | +|---|---| +| `fastlane/Fastfile` | `before_all` (Xcode pin) + public `release_testflight` + private helpers | +| `fastlane/Appfile` | `apple_id ENV["APPLE_ID"]`, `team_id ENV["TEAM_ID"]` | +| `fastlane/.env.default` | Project/target/bundle-id/profile configuration (full key list below) | +| `fastlane/Pluginfile` | **`fastlane-plugin-xcodegen` only** | +| `Gemfile` + `Gemfile.lock` | `fastlane` + plugins. No cocoapods, no slather. **Commit the lock** — the workflow's gem cache keys on `hashFiles('**/Gemfile.lock')`; without it the key is constant and fastlane silently re-resolves between two runs of the same tag. | +| `Brewfile` | `xcodesorg/made/xcodes`. **Not** `xcodegen` — see runner notes. | +| `HEALTHCHAT_CHANGES.md` | **New file** (D8). Seed with a `## Changes in 26.07.3 ()` section matching the awk pattern. | +| `.gitignore` | Add `out/` and **`provisioning_profiles/`** (signing material — security-relevant). `build` (`:35`) and `fastlane/report.xml` (`:28`) are already covered upstream. | + +**Drop `fastlane-plugin-versioning`.** The legacy carries it but never uses it — its `update_marketing_version` +is raw `File.read`/`gsub!`, and no `increment_version_number` / `get_version_number` / `update_info_plist` +call exists anywhere. Copying it into a file we call "slim and purpose-built" would propagate dead weight. + +### `.env.default` + +``` +PROJECT_PATH=ElementX.xcodeproj +SCHEME=ElementX +MAIN_TARGET=ElementX +NSE_TARGET=NSE +SHARE_EXTENSION_TARGET=ShareExtension + +MAIN_BUNDLE_ID=ch.healthchat.element +NSE_BUNDLE_ID=ch.healthchat.element.nse +SHARE_EXTENSION_BUNDLE_ID=ch.healthchat.element.shareextension + +DERIVED_DATA_PATH=build +IPA_NAME=ElementX +ARCHIVE_PATH=out/ElementX.xcarchive +BUILD_OUTPUT_DIRECTORY=out +BUILD_LOG_DIRECTORY=build/Logs/Gym/ + +PROVISIONING_PROFILES_PATH=./provisioning_profiles/ +MAIN_PROVISIONING_PROFILE_FILENAME=main.mobileprovision +NSE_PROVISIONING_PROFILE_FILENAME=nse.mobileprovision +SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME=share_extension.mobileprovision + +APPSTORE_CODESIGNING_IDENTITY="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)" +APPSTORE_SIGNING_CERTIFICATE="Apple Distribution: E-HealthSolutions GmbH (6G37K72W82)" +APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER="HealthChat App Store" +APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER="HealthChat NSE: App Store" +APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER="HealthChat Share Extension: App Store" + +TEAM_ID=6G37K72W82 +# APPLE_ID is a machine-level shell variable on the runner, not a repo secret. +``` + +`APPSTORE_CODESIGNING_IDENTITY` and `APPSTORE_SIGNING_CERTIFICATE` are **distinct keys** in the legacy — both +are needed. The `out/ElementX.ipa` artifact path is only true because `BUILD_OUTPUT_DIRECTORY`/`IPA_NAME` are +set here. + +Verified: bundle ids from `NSE/SupportingFiles/target.yml:75` and `ShareExtension/SupportingFiles/target.yml:77`; +schemes/targets from `ElementX/SupportingFiles/target.yml:3-4,41`, `NSE/SupportingFiles/target.yml:3-4,30`, +`ShareExtension/SupportingFiles/target.yml:3-4,30`. + +### `release_testflight` lane + +Mirrors `element-ios/fastlane/Fastfile:199-224`: accept inputs via options (local) or env vars (CI, avoiding +shell-escaping of multi-line notes), then: + +1. `app_store_connect_api_key(...)` from `APPSTORECONNECT_KEY_{ID,ISSUER_ID,CONTENT}` when `is_ci`. +2. `generate_build_number` → `YYYYMMDDHHMMSS` (ported from `Fastfile:562`). Monotonic; makes retries free. +3. `xcodegen(spec: "project.yml")`. +4. `download_provisioning_profiles` → three profiles (main, NSE, ShareExtension). +5. `disable_automatic_code_signing` + `update_project_provisioning` per target. +6. `build_app`, `export_method: "app-store"`, `CODE_SIGN_STYLE=Manual`. +7. `upload_to_testflight(skip_submission: true, distribute_external: false, changelog: release_notes)`. + +**Version injection.** element-x holds `MARKETING_VERSION: 26.07.3` / `CURRENT_PROJECT_VERSION: 1` in +`project.yml:56-57`, consumed by XcodeGen at *generate* time (unlike the legacy's `AppVersion.xcconfig`, read +at *build* time). Pass both via `xcargs` to `build_app` instead of rewriting `project.yml`. Verified sound: +NSE (`:51-52,76-77`) and ShareExtension (`:46-47,78-79`) both resolve their version keys from +`$(MARKETING_VERSION)`/`$(CURRENT_PROJECT_VERSION)` at build time, and xcodebuild command-line settings +override project *and* target level uniformly — so all three targets stay version-consistent. This matters: +App Store rejects an upload whose extension version disagrees with the app's. + +This spares `project.yml`, but **it does not mean "no tracked file is mutated"** — `ElementX.xcodeproj/project.pbxproj` +is tracked and steps 3 and 5 rewrite it (xcodegen regenerates; `disable_automatic_code_signing` / +`update_project_provisioning` write Manual signing and profile UUIDs). The pbxproj footgun **moved, it did not +vanish**. The skill must say: never commit the post-lane tree; `git checkout -- ElementX.xcodeproj`. + +### GitHub workflow + +`.github/workflows/release-healthchat.yml` — near-copy of the legacy's, which is already well-built (pinned +action SHAs, `contents: read`, concurrency group, 90-minute timeout, awk notes extraction, artifact upload). + +Changes from the legacy: + +- **`submodules: false`** — critical. element-x-ios's only submodule is the **private** + `element-hq/element-ios-enterprise` over SSH; the legacy's `submodules: 'true'` would fail checkout on step + one of every run. +- Drop the CocoaPods cache and `rm -rf Pods/MatrixSDK`. +- Drop `MX_GIT_BRANCH` (legacy `:41`) — no `mx_git_branch` helper exists in the slim Fastfile. +- Re-evaluate `fetch-depth: 0` (legacy `:48`); keep only if something needs history. +- Keep the explicit `brew bundle` step (legacy `:115`). +- `bundle exec fastlane release_testflight`; artifacts `out/ElementX.ipa`, `out/ElementX.app.dSYM.zip`. +- Harden the notes step with `[[ -f HEALTHCHAT_CHANGES.md ]]`. The legacy's "warning fallback" only fires on + an *empty match*, not an absent file — under `set -euo pipefail` a missing file aborts the job. + +**Do not carry `HEALTHCHAT_KEYCHAIN_PASSWORD` forward as mandatory.** The legacy skill lists it among six +required secrets and the legacy workflow references it, but it exists at neither repo nor org level — and the +pipeline runs green regardless. `apple-actions/import-codesign-certs` tolerates its absence. **Five secrets is +the real requirement.** + +### Runner + +`novaloop-mac-studio-001` — org-level, online, labels `[self-hosted, macOS, ARM64]`; already satisfies +`runs-on: [self-hosted, macOS]`. Confirm the org runner group's visibility includes element-x-ios (two +`Default` groups exist, one `all`, one `selected`). + +**Xcode pin: use `"26.4"`.** The runner has it and the legacy pipeline proves it works. +`ci_scripts/ci_common.sh:34` (`sudo xcode-select -s /Applications/Xcode_26.5.0.app`) is an **Xcode Cloud image +path, not an `xcodes` version identifier**, and is not evidence the runner has 26.5. If element-x turns out to +need 26.5, install it on the runner manually **first** — otherwise `xcodes` attempts a multi-GB authenticated +download needing `XCODES_USERNAME`/`XCODES_PASSWORD`, which none of the five secrets supply, and hangs in +`before_all`. + +Also required on the runner: `xcodegen` on PATH (installed manually once, deliberately **not** in `Brewfile` +so `brew bundle` cannot churn it), Homebrew, Ruby + bundler, and `APPLE_ID` exported as a machine-level shell +variable (the `Appfile` reads it; it is not repo-carried). + +### Secrets to copy + +element-x-ios has **zero** repository secrets. Copy these five from `Novaloop-AG/element-ios`: +`HEALTHCHAT_ASC_KEY_ID`, `HEALTHCHAT_ASC_KEY_ISSUER_ID`, `HEALTHCHAT_ASC_KEY_CONTENT`, +`HEALTHCHAT_CERTIFICATES_P12`, `HEALTHCHAT_CERTIFICATES_P12_PASSWORD`. + +Requires the `snowping` GitHub account — `Novaloop-AG/element-x-ios` is INTERNAL and invisible to +`joel-poyet_GALGH`. + +--- + +## Part 3 — Release skill (PR3, `pr-doc`) + +`.agents/skills/releasing-healthchat/SKILL.md`, reachable as a project skill through the existing +`.claude/skills -> ../.agents/skills` symlink. + +Mirror the legacy skill's shape — it is a good model: two-field frontmatter (`name`, `description`), terse +imperative prose, checklists and tables, **rationale inline on every prohibition**. Sections: preflight, +cutting a release, `workflow_dispatch` retry path, tag format, release notes, post-release checks, required +secrets, runner requirements, certificate renewal, TestFlight → App Store promotion, rollback, divergence. + +Corrected for this repo: + +- **Five** required secrets, not six. +- Version source is `project.yml:56`, not `Config/AppVersion.xcconfig`. +- Three profiles (main, NSE, **ShareExtension**), not main/NSE/broadcast. +- **Never commit the post-lane tree** — `git checkout -- ElementX.xcodeproj` after a local run. +- **Test versions must be numeric-only** (see Verification) — this trap costs 30 minutes per hit. +- The divergence section shrinks: `fastlane/` and the release workflow are fork-only here, so the legacy's + `--ours` guidance for `.env.default`/`Fastfile` does not apply. What *does* need calling out: + `automatic-calendar-version.yml` and `post-release.yml` are gated on + `github.repository == 'element-hq/element-x-ios'` and silently no-op in the fork, so rebases drag + `project.yml`'s version along — **the tag is the source of truth**. +- **New section — the migration contract** (no legacy equivalent): `CLASSIC_APP_*` in `healthchat.yml` is + load-bearing for existing users and fails *silently*; it must never be "cleaned up" to upstream's values. + The `.requiresBackup` edit in `AuthenticationStartScreenViewModel` is a deliberate upstream divergence — a + rebase that reverts it reintroduces a broken CTA. Both apps share the `group.ch.healthchat` UserDefaults + suite (`AppSettings.swift:56`), so unqualified `@UserPreference` property names land in a suite the legacy + also writes to — future key collisions are cheap to introduce. + +Add a 2-line delegation in `AGENTS.md` pointing at the skill rather than duplicating the runbook — the legacy +does exactly this (`element-ios/CLAUDE.md:288-290`). element-x's `AGENTS.md` has no release section today, and +per its own "keep current" rule this is a new fact about the repo, so it belongs there, in caveman voice. + +--- + +## Landing plan + +`Dangerfile.swift:50-51` **fails** the PR unless it carries exactly one `pr-` label. (The empty-body rule +`:24` and the >1000-additions rule `:18-19` are `warn`s, not fails.) + +| PR | Contents | Label | +|---|---|---| +| PR1 | Part 1 — `CLASSIC_APP_*`, entitlement, `.requiresBackup` edit, regenerated pbxproj | `pr-bugfix` | +| PR2 | Part 2 — fastlane, workflow, Gemfile(.lock), Brewfile, .gitignore, HEALTHCHAT_CHANGES.md | `pr-build` | +| PR3 | Part 3 — skill + AGENTS.md delegation | `pr-doc` | + +AGENTS.md's 500-addition guidance argues for keeping these separate rather than combining PR2 and PR3. + +--- + +## Apple Developer portal prerequisites + +Manual, one-time, **blocking** — no signed archive is possible until all three are done. + +1. **Create App ID `ch.healthchat.element.shareextension`** with App Groups + Keychain Sharing, then the + `HealthChat Share Extension: App Store` profile. Genuinely new: the legacy provisioned + `ch.healthchat.element.shareExtension` — **camelCase, a different string** — and disabled the target anyway + (`element-ios/fastlane/.env.default`: `SHARE_EXTENSION_TARGET=`). element-x embeds ShareExtension + unconditionally (`ElementX/SupportingFiles/target.yml:227-228`). +2. **Add `com.apple.developer.usernotifications.communication`** to `ch.healthchat.element`; regenerate + `HealthChat App Store`. New vs. legacy (absent from `Riot/SupportingFiles/Riot.entitlements`). +3. **Add `com.apple.developer.usernotifications.filtering`** to `ch.healthchat.element.nse`; regenerate + `HealthChat NSE: App Store`. New vs. legacy (`RiotNSE.entitlements` has only app-groups + keychain). + +The `HealthChat Broadcast Upload Extension: App Store` profile becomes orphaned — leave it; the legacy +pipeline still uses it. + +Certificate: **not due.** `HEALTHCHAT_CERTIFICATES_P12` was updated 2026-04-07 and the workflow ran green +2026-04-15. Apple Distribution certs last one year → renewal due around **April 2027**. + +--- + +## Verification + +### Part 1 — device-only + +**Step 0 (or the test silently false-negatives).** `AuthenticationService.swift:49-52` short-circuits +`loadAccounts()` entirely when `appSettings.hasSignedInBefore` — which lives in the `group.ch.healthchat` +UserDefaults suite (`AppSettings.swift:56`), survives app deletion via the shared container, and is never +cleared. **Any dev device that has previously run a HealthChat element-x build will show a plain login screen +while the fix works perfectly.** Delete the app before step 1. A `"skipping loadAccounts"` log line means the +run is **void, not failed**. + +1. Install the *shipping* legacy HealthChat on a physical device. Sign in. Send an encrypted message so the + crypto store is populated. +2. Build element-x with the fix and install **over it** — same bundle id → in-place update. **Do not delete + the app first**; that destroys the keychain and container and invalidates the test. +3. Expected: the app finds the account and migrates without a re-login. +4. Confirm the legacy MXID is `@user:healthchat.ch` (the D2 precondition). +5. Exercise **both** secrets paths if possible: `.complete` (migrates) and `.requiresBackup` (must fall + through to sign-in, never the backup sheet). +6. Watch the log for `ClassicAppManagerError` — the failure is silent in the UI. +7. **Send a push notification to the migrated device.** Part 2 adds two brand-new notification entitlements + and a first-ever NSE profile; nothing else exercises delivery, and upload proves signing, not push. + +### Part 2 — pipeline + +Use `workflow_dispatch` with a throwaway version, as the legacy skill prescribes for cert renewal: + +1. Run "Release HealthChat" manually with version **`26.7.99`** and a release-notes override. + **The version must be numeric-only.** It lands verbatim in `CFBundleShortVersionString` (`Info.plist:36-37`) + and Apple requires ≤3 period-separated integers. A marker like `26.07.3-pipeline-test` passes the + workflow's own regex (`:59`) but earns an **ITMS rejection at the final step, after ~30 minutes of + signing** — indistinguishable from a real signing failure. Put markers in the tag or release notes only. +2. Green run → build appears in App Store Connect → TestFlight within ~10 minutes. +3. Discard the build. + +Do **not** verify by submitting to the App Store. TestFlight acceptance proves signing, entitlements and +upload; review proves nothing extra about the pipeline. + +`swiftformat --lint .` and SwiftLint run on build and are currently clean. + +--- + +## Risks & open questions + +| # | Risk | Status | +|---|---|---| +| **O1** | **Was the legacy build on the Rust crypto store?** `cryptoSDKStoreKey` is generated unconditionally (`EncryptionKeyManager.swift:42`), but whether the store is Rust-backed decides if `SecretsBundleWithUserId.fromDatabase` can read it. If it was still Olm, D2 collapses to "forced re-login" and needs product sign-off. **Settle on a real device before the first submission.** | +| **O2** | **iOS floor rises 15.1 → 18.5** (`element-ios/Config/Project.xcconfig:29` vs `project.yml:14-15`). Every user below 18.5 silently stays on 1.4.1 forever — the App Store will not offer the update. Not fixable (upstream's floor); check the install base before shipping. | +| **O3** | **Pusher overwrite.** element-x's `pusherAppID` and device pushkey are identical to the legacy's, so first launch overwrites the user's sygnal pusher in place and the format changes to `.eventIdOnly`. Benign under D1 — but a rollback to 1.4.1 loses push. | +| **O4** | **Runner group visibility** — confirm `novaloop-mac-studio-001` is reachable from element-x-ios. | +| **O5** | `Secrets/Secrets.swift` holds placeholders; `Secrets/Secrets.pkl:12-17` wants MAPLIBRE_API_KEY among others. Maps degrade without it. Not a release blocker — track separately. | +| **O6** | Cosmetic Element strings ship in a HealthChat binary, e.g. `BGTaskSchedulerPermittedIdentifiers: io.element.elementx.background.refresh` (`ElementX/SupportingFiles/target.yml:102-104`). Working; cosmetic only. | + +**Closed:** the app-group collision question — container paths are disjoint, and the shared +`group.ch.healthchat` UserDefaults suite has a verified-empty key intersection. Captured as a standing hazard +in the Part 3 migration contract rather than an open risk. + +### Corrected prior belief + +An earlier session recorded that a legacy app-lock PIN could lock the new build out via a shared keychain +group. **Refuted.** The two differ in *group, service and account* — `…keychain.shared` vs `…element`; +`…pin-service` vs `…keychain.sessions`; `pin` vs `appLockPINCode` +(`element-ios/Riot/Modules/SetPinCode/PinCodePreferences.swift:20-24,37-38` vs +`ElementX/.../KeychainController.swift:21-23,34-37`). The groups were never shared. The conclusion +("harmless") was right; the mechanism was wrong — and the wrong mechanism is what motivated dropping the +classic keychain group in `01dc703b4`. + +## Out of scope + +App Store submission automation (needs `fastlane/metadata/` + HealthChat screenshots, neither exists), German +localisation, the `to.healthchat.ch` links service, blue outgoing bubbles.