Design for HealthChat App Store release capability

Spec for bringing App Store release capability to element-x-ios, targeting the
existing HealthChat record that the legacy element-ios fork ships to today.

Both apps share bundle id ch.healthchat.element and team 6G37K72W82, so element-x
arrives as an in-place update rather than a new listing. That drives the design:
the identity already aligns and 26.07.3 clears the legacy's 1.4.1, but the repo
has no release machinery at all, and upstream's Xcode Cloud path is unavailable
because it needs the private Enterprise submodule.

Records three parts: repointing CLASSIC_APP_* at HealthChat's own identifiers so
existing users keep their sessions and encrypted history, a fork-owned fastlane
pipeline ported from the legacy fork, and a releasing-healthchat skill.

Revised after an adversarial review of every factual claim; 26 issues found, none
rejected. Most consequential: the migration fix is not monotonic, because making
loadAccounts() succeed exposes a backup-instructions sheet whose only button opens
element://open — a scheme this app does not register and whose legacy owner has
been replaced in place.
This commit is contained in:
2026-07-15 09:53:18 +02:00
committed by Sedat Qaja
parent 3d1fa27a4b
commit 2835a5b10d
@@ -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/<version>` (`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 (<date>)` 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.