Remove the internal superpowers planning docs (#5)

These two files are internal AI planning and design artifacts from setting up the
HealthChat release pipeline, not documentation meant to ship in the repo. Nothing
references them, and docs/FORKING.md remains the actual fork documentation.

Claude-Session: https://claude.ai/code/session_01PeF4yE5SDZV3zjXWk73osw
This commit is contained in:
2026-07-17 17:10:56 +02:00
committed by Sedat Qaja
parent c352843fa6
commit 9969af6984
2 changed files with 0 additions and 1776 deletions
File diff suppressed because it is too large Load Diff
@@ -1,470 +0,0 @@
# 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` | Public `release_testflight` + private helpers. **No `before_all`, no Xcode pin** — see runner notes. |
| `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. |
| `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.
- **Drop the `brew bundle` step** (legacy `:115`) — there is no `Brewfile`, and the runner is provisioned by
hand. See runner notes.
- `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`).
**No Xcode pin.** Measured on the runner today (user `novaloop`, arm64, macOS 26.5.2): exactly **one** Xcode,
`/Applications/Xcode.app` — **26.6, build `17F113`**, `xcode-select`ed and the only version `xcodes` lists. No
versioned `Xcode_*.app` siblings exist.
An earlier draft said to pin `"26.4"` because "the runner has it and the legacy pipeline proves it works".
**Both halves were wrong.** The runner does not have 26.4 and never will; a pin naming an absent version does
not fail fast — `xcodes` starts a multi-GB authenticated download needing `XCODES_USERNAME`/`XCODES_PASSWORD`,
which none of the five secrets supply, so `before_all` hangs and takes the release with it. Since the runner
carries a single globally-selected Xcode, whatever `xcode-select` points at *is* the build toolchain, and a pin
could only ever disagree with reality. **Drop it entirely**; the Xcode version becomes a property of the
machine, changed by provisioning it rather than by editing a tracked file. The reasoning about
`ci_scripts/ci_common.sh:34` (`sudo xcode-select -s /Applications/Xcode_26.5.0.app`) still stands — it is an
Xcode Cloud image path, not an `xcodes` version identifier, and says nothing about this runner.
The cost of dropping it: **whatever the runner has is what ships**, and no release run records which Xcode
built it (`release-healthchat.yml` does not log `xcodebuild -version`; `unit-tests.yml` does). Upgrading the
runner's Xcode is therefore a release-affecting change with no tag, diff or review. Accepted — the alternative
pins a version that does not exist. Captured in the skill.
**The runner is provisioned by hand, never by CI.** Upstream's `setup_github_actions_environment`
(`ci_scripts/ci_common.sh`) cannot run here: it calls `sudo xcode-select -s /Applications/Xcode_26.5.0.app` —
there is **no passwordless sudo** and no such path — and `brew update && brew install …`, which on this
*shared release machine* could upgrade `xcodegen` underneath a running release. CI must **verify and fail
loudly**, naming the fix, instead.
Present today: `xcodes`, `xcodegen`, `git-lfs`, `brew`, `swift`, rbenv `ruby`/`bundle`/`gem`, and `APPLE_ID`
exported as a machine-level shell variable (the `Appfile` reads it; it is not repo-carried).
**Missing today** — the human installs these once, before the test workflow can pass: `swiftlint`,
`swiftformat`, `pkl`, `xcresultparser`, `xcbeautify`. Only `xcresultparser` is outside homebrew-core
(`a7ex/homebrew-formulae/xcresultparser`). The unit-test job asserts all of them. The release lane requires
none: its SwiftLint/SwiftFormat build phases are `if which …` guarded and degrade to a build warning when the
tool is absent (`ElementX/SupportingFiles/target.yml:202-214`), and nothing in the build reads `pkl`.
### 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), .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. |
| **O7** | **The legacy element-ios pipeline is probably broken right now — unverified, and worth verifying.** It pins `xcodes(version: "26.4")` and runs on this same runner, which carries only **26.6**. Its last green run was 2026-04-15, so the machine plausibly had 26.4 then and lost it to an upgrade since. If so, the next legacy release does not fail — it **hangs** in `before_all` on an authenticated multi-GB download, and only a timeout ends it. **This fork no longer depends on that pipeline** (D3's "the legacy pipeline is proven" means *was* proven, on a toolchain the machine no longer has), so this is not a blocker here. Confirm the pin against `Novaloop-AG/element-ios` and de-pin it there before its next release. Out of scope for these three PRs — raise it. |
| **O8** | **Whatever Xcode the runner has is what ships.** The pin is gone (see Runner), so upgrading `/Applications/Xcode.app` silently changes the compiler for every subsequent release, with no tag, diff or review. `release-healthchat.yml` records it via the `Record toolchain` step (`xcodebuild -version`) before every build, so each run logs the compiler that produced it — but that log line is the only record, and it detects the change after the fact rather than preventing it. Treat a runner Xcode upgrade as a release-affecting change. |
| **O9** | **A pull request can execute arbitrary code on the release machine.** `unit-tests.yml` runs on `[self-hosted, macOS]` under a `pull_request` trigger (`.github/workflows/unit-tests.yml:9,20`), so any PR's build scripts run on `novaloop-mac-studio-001` — the same persistent runner, keychain and signing identities the release pipeline uses. **Accepted because the repo is INTERNAL**, which narrows PR authors to org members. The trade-off is invisible to CI: zizmor reports it (`self-hosted-runner`, medium) **only at `--persona=auditor`**, and the default persona CI runs suppresses it — so no required check will ever warn. If org membership stops being a sufficient trust boundary: restrict the trigger to non-fork branches, or move back to GitHub-hosted `macos-26` with a raised `timeout-minutes`. |
**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.