iOS 0.32: merge the release branch into main, add its Spanish, and move CI to Gitea Actions #319

Closed
extant2000git wants to merge 0 commits from merge/ios-0-32-unify into main
Owner

Closes the iOS half of the 0.32 unification, adds the Spanish the release branch never had, and moves CI to Gitea Actions. Replaces PR #315, which cannot merge.

Verified on this machine, Xcode 27 / Swift 6.4

check result
xcodebuild build BUILD SUCCEEDED
FlamenetMessengerTests 1289 tests, 0 failures
FlamenetMessengerUITests 91 tests, 72 skipped for want of a live relay, 0 failures
Scripts/check-spanish-catalog.py 1466 keys in code, 0 missing
Scripts/secret-sweep.sh clean, 739 files
Scripts/parity-scrub.py 0 findings against the merged Android tree

The resolutions worth a human read

32 conflict hunks across 21 files. Neither side is the answer where the conflict is about what the app does.

  • Info.plist and project.yml keep BOTH URL types. One claims flamenet for contact links, the other flamenetmessenger for hello links; taking either alone stops the other kind of link opening the app.
  • CardScanner: git had already merged the controller to the union of the injected accepts predicate and the new onRefused callback, and the call sites now match. accepts is the only gate, because hard-coding ScannedCode there would refuse every code the Devices screen scans. The address sheet's default is ScannedCode.card, so an older card's ?u= username is still never trusted (DESIGN 32).
  • E2EInbox: seedAccountStateFromDeviceIfNeeded() sits in the factory rather than in the release branch's new connect(). It reads this device's own store and never the relay, and the offline launch path passes connect: false, which would otherwise show every existing conversation as a request until the relay answered. deliverRemote keeps both gates.
  • MainTabView and PillTabBar: the extracted PillTabItem and tabItems() are kept, with identifier put back and every title through String(localized:). title is a plain String, so bare literals would have left every tab English in every language.
  • ParentalControlsView: the release branch's fuller footer as ONE literal, because Text localizes a literal and not a + chain.

Six test expectations moved, each decided by the spec

Five of the six failing test files exist only on the release branch and were written against behaviour main has since changed. In each case the production behaviour stands:

  • The snooze tests never accepted their sender. Section 14.5 makes an unaccepted sender a Request, which is a quiet badge and never a notification, so without accept every check would pass on an app that posts nothing.
  • HelloLinkTests asserted one URL scheme. Both are registered on purpose.
  • EveryScannerBindsByFnid searched for CardScanner { only; main's call sites take arguments.
  • SupervisionHidesToday asserted a one-line ternary that swift-format now wraps.
  • UITestSafetyTests caught a real gap: SpanishScreensUITests built XCUIApplication() directly, so its launches were not marked FNMSG_TEST_LAUNCH. Fixed in the suite, not in the gate.
  • DirectoryShareTests could not observe the preview fetch at all, because main moved it to URLSession(configuration: .ephemeral) so a preview carries no cookies to the linked site, and a session built from a configuration does not see URLProtocol.registerClass. LinkPreviewFetcher.protocolClasses is the seam; it is nil in the app.

Spanish

79 keys had no es value, all from the release branch's features plus the two scanner refusals added here. Terms follow docs/i18n/glossary-es.md and Android's wording where Android already shipped the same label. Every entry is needs_review, like the rest of the column.

Spec references

35 references renumbered. a3b05c3 in flamenet-e2e moved anonymous reads and contact links to §8.13 and §8.14 to make room for the push token and the Today file, so every "8.11, anonymous reads" and "8.12, contact links" in this tree pointed at the wrong section.

Ordering

The parity-scrub job clones flamenet-messenger-android main, so it stays red until that repo's own 0.32 merge lands. Against the merged Android tree it reports 0 findings. Merge Android first, or expect that one job red here.

🤖 Generated with Claude Code

Closes the iOS half of the 0.32 unification, adds the Spanish the release branch never had, and moves CI to Gitea Actions. Replaces PR #315, which cannot merge. **Verified on this machine, Xcode 27 / Swift 6.4** | check | result | |---|---| | `xcodebuild build` | BUILD SUCCEEDED | | `FlamenetMessengerTests` | 1289 tests, 0 failures | | `FlamenetMessengerUITests` | 91 tests, 72 skipped for want of a live relay, 0 failures | | `Scripts/check-spanish-catalog.py` | 1466 keys in code, 0 missing | | `Scripts/secret-sweep.sh` | clean, 739 files | | `Scripts/parity-scrub.py` | 0 findings against the merged Android tree | **The resolutions worth a human read** 32 conflict hunks across 21 files. Neither side is the answer where the conflict is about what the app does. - `Info.plist` and `project.yml` keep BOTH URL types. One claims `flamenet` for contact links, the other `flamenetmessenger` for hello links; taking either alone stops the other kind of link opening the app. - `CardScanner`: git had already merged the controller to the union of the injected `accepts` predicate and the new `onRefused` callback, and the call sites now match. `accepts` is the only gate, because hard-coding `ScannedCode` there would refuse every code the Devices screen scans. The address sheet's default is `ScannedCode.card`, so an older card's `?u=` username is still never trusted (DESIGN 32). - `E2EInbox`: `seedAccountStateFromDeviceIfNeeded()` sits in the factory rather than in the release branch's new `connect()`. It reads this device's own store and never the relay, and the offline launch path passes `connect: false`, which would otherwise show every existing conversation as a request until the relay answered. `deliverRemote` keeps both gates. - `MainTabView` and `PillTabBar`: the extracted `PillTabItem` and `tabItems()` are kept, with `identifier` put back and every title through `String(localized:)`. `title` is a plain String, so bare literals would have left every tab English in every language. - `ParentalControlsView`: the release branch's fuller footer as ONE literal, because `Text` localizes a literal and not a `+` chain. **Six test expectations moved, each decided by the spec** Five of the six failing test files exist only on the release branch and were written against behaviour main has since changed. In each case the production behaviour stands: - The snooze tests never accepted their sender. Section 14.5 makes an unaccepted sender a Request, which is a quiet badge and never a notification, so without `accept` every check would pass on an app that posts nothing. - `HelloLinkTests` asserted one URL scheme. Both are registered on purpose. - `EveryScannerBindsByFnid` searched for `CardScanner {` only; main's call sites take arguments. - `SupervisionHidesToday` asserted a one-line ternary that swift-format now wraps. - `UITestSafetyTests` caught a real gap: `SpanishScreensUITests` built `XCUIApplication()` directly, so its launches were not marked `FNMSG_TEST_LAUNCH`. Fixed in the suite, not in the gate. - `DirectoryShareTests` could not observe the preview fetch at all, because main moved it to `URLSession(configuration: .ephemeral)` so a preview carries no cookies to the linked site, and a session built from a configuration does not see `URLProtocol.registerClass`. `LinkPreviewFetcher.protocolClasses` is the seam; it is nil in the app. **Spanish** 79 keys had no `es` value, all from the release branch's features plus the two scanner refusals added here. Terms follow `docs/i18n/glossary-es.md` and Android's wording where Android already shipped the same label. Every entry is `needs_review`, like the rest of the column. **Spec references** 35 references renumbered. `a3b05c3` in flamenet-e2e moved anonymous reads and contact links to §8.13 and §8.14 to make room for the push token and the Today file, so every "8.11, anonymous reads" and "8.12, contact links" in this tree pointed at the wrong section. **Ordering** The `parity-scrub` job clones `flamenet-messenger-android` main, so it stays red until that repo's own 0.32 merge lands. Against the merged Android tree it reports 0 findings. Merge Android first, or expect that one job red here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
extant2000git added 68 commits 2026-10-14 15:04:08 +00:00
The picker shows each pack under `stickers/` as its own section: the core
pack first, then FlameNet's 24 drawings (`flamenet.retro`). A sticker draws
its image when it has one, its glyph when it has none or the image will not
load, and its pack's fallback when this build does not know the id. A pack
this build does not ship draws the core fallback, as docs/STICKERS.md
describes.

`Resources/stickers` is now a folder in the bundle, not a group, so each
pack's PNGs sit in a folder named for the pack and two drawings with the same
file name cannot collide at the bundle root. The SVG sources and the render
script are in `Art/stickers/`; the PNGs are the bytes the Android app ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three content kinds that travel inside the ratchet, so the relay sees nothing
new: `card`, a greeting card that can stay sealed until a date; `event`, an
invitation its organizer can revise or cancel under a higher `rev`; and
`rsvp`, an answer, marked `ctl` so older builds drop it.

Both apps write the same bytes: compact JSON, keys in the shared order,
slashes left unescaped. Reading is lenient: unknown fields are ignored, flags
take 1 or true, a number with a fraction is read as a whole number, a field
of the wrong type is absent, and a card or event that carries `ctl` is
consumed as control.

A card or an event lives on the message it arrived on, so history, the
disappearing timer, delete and off the record (held in memory, filtered from
every save) apply with no new store. A revision folds onto the card it
revises: the highest `rev` wins, and only the sender of rev 1 may revise. An
answer folds by the highest `n` per person, never notifies, and goes to every
member of a group; one that arrives before its event waits in memory for it.
With no sent copies yet, a proven envelope from this account's own device is
placed as its own in a group and dropped in a one-to-one chat.

The backup carries `card`, `ev`, `org` and `rsvp` on each message, in the
shape both apps read. The field test now reads the whole type, so the fields
declared after a computed property on a message are checked too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Card and Event join Photo and File in a one-to-one chat's attach menu, and a
group's paperclip becomes the same menu. A card is one of the nine
flamenet.io fronts, shipped as `cards/<design>.png` with its title drawn as
text, and can stay sealed until a day up to 400 days ahead. The receiver sees
the front and "Opens on <date>" until then, and the card opens by itself on
the day; the sender's copy says "Sealed until <date> for <name>". Copy,
reply, forward, report, the conversation list and the notification never
show the words of a card that is still sealed.

An event card shows when and where, the organizer's time when their clock
reads differently, Going, Maybe and Can't go with counts and names, and
"Everyone in this chat sees your answer." The organizer can change or cancel
it. Add to calendar opens the system's own add-event sheet, which on iOS 17
and later needs no calendar access and no Info.plist key; a test presents it
and checks that nothing asked.

`-FNMSGKindsPreview` (debug builds only) shows the new screens with sample
data, for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local SwiftPM package with no dependencies: chess, checkers, reversi
and tic-tac-toe as DESIGN.md section 2 describes them. Moves are strings
in the wire notation, positions are immutable values, and apply accepts
exactly the strings legalMoves returns, so a log replays the same way on
both apps. Replay refuses a log over the game's cap before playing it and
names the index of the first move it cannot play.

The tests run games-v1.json, which the Android :games module runs byte
for byte: the perft counts from DESIGN.md and 147 cases. Deeper published
perft counts run with FLAMENET_GAMES_DEEP=1.

Not wired into project.yml or the app target yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core pack and FlameNet's both have a `star`, a `cake` and a `coffee`.
The picker keyed its cells by sticker id alone, so SwiftUI drew the second
of each pair as an empty cell. `Sticker.key` is the pack and the id together,
the picker uses it, and a test checks the shipped keys are unique. The picker
also takes a height, for the debug preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sealed card read the time from its timeline's context, and the first draw
can be handed the scheduled opening time itself, so a card still sealed drew
as open: no "Opens on" under it and no "Sealed until" under the sender's
copy. It now reads the clock each time it draws and is woken a second after
it opens. Add to calendar and Edit take the deeper blue, which stays
readable on the dark card. The preview shows both sealed lines on one screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings events in line with the Android app:

- An answer's `n` is the unix second it was given, or one past the last `n`
  this device sent for the event when that is later. The highest `n` wins,
  and on an equal `n` the answer that sorts higher as a string (`yes`, then
  `no`, then `maybe`), so every phone settles on the same answer whatever
  order they arrive in. This also covers one person answering from two
  devices.
- A cancellation notifies "<title> was cancelled" and the card reads
  "Cancelled: <title>". A revision notifies "<title> changed" by the title
  the reader had before. A rev 1 that settles a provisional organizer, a
  duplicate and a stale revision say nothing.
- An event id only has to be a non-empty string.
- The organizer's time line names the weekday when the organizer's date
  differs from the reader's ("Wed 7:00 PM New York time").
- The backup writes a card or an event with empty `text`, whatever the
  message held, so a sealed card's words stay inside `card`.

Code and test names now cite §6.31 (greeting cards) and §6.32 (events and
answers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Describes the packs as they ship: every manifest under `Resources/stickers/`
is a pack, a sticker is named by pack and id together, `image` lives in a
folder named for the pack, what a sticker draws when something is missing,
and one picker section per pack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app target depends on the local games package from the merge before
this, so the chat code can replay a game from its log. Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A games-engine job runs `swift test --package-path Packages/FlamenetGames`
on the mac-local runner for merge requests and main, the same rules as the
other jobs. It needs no simulator; a cold run on this Mac took 12 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A game is played inside a one-to-one chat (§6.33). `game` starts it and is
the message its board lives on. `gmove` carries the whole log after each
move and `gend` an ending or a draw offer, both as control traffic. Each
phone rebuilds the board from the log with the engine's capped replay and
checks that every new ply is the sender's and legal. A log that disagrees,
a move for this person's side or a log past the cap stops the game as out
of step, and nothing more is taken for it.

A draw is offered only by the side that has just moved and accepted only
against that pending offer. The invitee can decline and the starter can
cancel until the invitee's first move, judged on the log the gend carries,
so a cancel that crosses that move ends the game on both phones. A move for
a game this phone never saw rebuilds it from the log. A move whose send
fails is taken back with an error and never queued; the start is queued
like a card.

Moves and endings are announced, and the app says them itself: with no
sound for "Your move: chess with Sam" and "Sam offers a draw: Chess", with
one for an invitation and for "Chess with Sam: They resigned. You won".
Nothing is said for this person's own moves or a turned-down offer.

A group's game draws as unsupported and its moves are dropped; a start
from another relay draws as unsupported too. The game lives on its start
message, so history, the disappearing timer, delete and off the record
cover it. The backup carries the game object both apps read: id, t, me,
log, and r with rby when a gend ended it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Game is in the one-to-one attach menu: pick chess, checkers, reversi or
tic-tac-toe and who goes first. The card shows the board as the engine
draws it, turned round for the second player (tic-tac-toe keeps its
numbers in place), whose move it is, and the moves so far under a fold.

Tap a piece and the squares it can reach are marked; tap one to move. A
pawn reaching the far rank asks what it becomes. A checkers jump is entered
landing by landing and sent once it is a whole move. Reversi marks its
legal squares and shows a lone Pass button when that is the only move.
Every square reads out its name and what stands on it.

The buttons sit in one row: Decline or Cancel invitation while the
invitation is open, Resign and Offer a draw after it, Accept draw and
Decline on an offer, and Start a new game once the boards disagree, which
sends a fresh game of the same kind with the same sides. A game is never
offered for forwarding.

`-FNMSGKindsPreview` (debug builds only) has a mode for each board, a
finished game, an invitation, the out-of-step card and the new-game sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A card or an event from someone on another relay was kept on the message
but the screen drew only its text, which is empty for both, so it showed
as an empty bubble. The thread now draws the card, or the event card
without buttons, since nothing there can answer or change one, and the
conversation list shows the same summary line as other chats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sticker that named a group went into the sender's one-to-one thread, and
a group had no way to send one. A sticker with `gid` is now a group message,
delivered and notified like a group text, and the group composer has the
same sticker control as the one-to-one composer. The group envelope carries
`gid` and the group's name in `gname`, with one `mid` for the whole fan-out,
and members with no device yet get it from the queue like any group send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A group message that reached nobody at first, because no member had a
device yet, said "pending" for ever. Its queued copies carried an empty
`mid`, and the restate after they went matched only a bubble with no `mid`
at that moment, while every group send gives its bubble one. The queued
copies of group text, photos, stickers, cards and events now name their
bubble, and the restate after a flush, or after a refusal waiting cannot
fix, finds it by that id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Today tab shows one public file from FlameNet (SPEC §8.11), served by
the home relay at /today.json and the same for everybody. This is the part
underneath the screen.

The file is shown only when its Ed25519 signature checks out against a key
compiled into the app, chosen by the kid the app computes for each pinned
key. The development key is pinned in DEBUG builds only; the production list
is empty until the release step creates that key, so a build without it
verifies nothing. Anything over 512 KB, unparseable, of another version,
signed by an unknown or wrong key, or without a valid date is refused and the
last good copy stays. Lists are cut to their bounds, strings are cut and
flattened, only http(s) links survive, and an older file never replaces a
newer one.

The request carries nothing about the person: its own ephemeral session with
no cookie store, no credential and no cache, a fixed User-Agent and
Accept-Language, no query string, and the obfs4 bridge applied as the other
relay clients apply it. It goes out at most once an hour, counted from the
relay's last answer, and five minutes after a connection that got none
(DESIGN decision 19). If-None-Match is sent only when the stored ETag is the
SHA-256 of the copy held, so no relay can tag a phone. The last good
envelope is kept on disk, excluded from backups, and checked again when it is
read back.

Tests sign their own files with keys they make, plus one fixture signed by
the development key with OpenSSL. The request is checked twice: as built, and
on the wire through a loopback server, with a control proving that server
sees a cookie when one is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
directory/directory-v1.json is FlameNet's export of public, approved
listings: 2,964 sites in 90 categories. It is read from the app bundle, so
browsing and searching it ask nothing of any server and work with no
connection. Search folds case, accents and width, needs every word typed, and
looks at the title, the host and the description. Sites with no category are
found by search only, as on the site. A description is optional, and the
file's note about who wrote the descriptions is kept to be shown once.

"Send to a chat" sends an ordinary 1:1 text holding the link, with a §6.12
preview made from the directory itself, so nobody fetches the site, not the
sender and not the receiver. The preview carries the title only: the
descriptions were drafted with software and a preview reads as the site's own
words (DESIGN decisions 6 and 18). desc is present and empty, because iOS
builds up to 0.31 cannot read a preview without it. With link previews
turned off the link goes alone.

E2EInbox.send takes the preview as an optional argument; when given, it is
sent as it is and the fetcher is not called.

Checked between two inboxes on a stub relay: the receiver gets the link and
the title-only preview, and a trap on the shared URL session saw no request,
while the same link sent the ordinary way was fetched, so the trap works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Today sits in the tab bar right after Messages, as on Android: Home,
Messages, Today, Calls, Stories, Files, Wallet, Account. The file is asked
for when the tab opens and when the app comes back to the front, within the
hourly rule. The sections run in SPEC §8.11's order: headlines with their
source and the host each link goes to, markets under "Prices are delayed.
Not advice.", weather for the cities picked on this phone in °C or °F as the
phone's region and temperature setting say, the site of the day with the
file's note about its description, the blog post when there is one, then the
way into the directory and the credits. Every link opens in the browser on a
tap and not before. The header says when the file was made; offline or
refused, the kept copy shows with one plain line saying why. A relay that
answers 404 gets only "Today's page is not offered by this relay." and the
directory.

The directory browses by category and searches on the phone; the paper plane
on a site opens "Send to a chat", which lists 1:1 contacts and shows what will
be sent. Both pickers draw their search box on the chrome: a .searchable
field sits at the foot of a sheet on this system, light, where the app's
white search text cannot be read.

Eight tabs is one more than the bar was drawn for. On a 375pt phone each tab
has 46pt and "Messages" is 48pt wide, 51 in bold, so tab labels may shrink to
75 percent rather than lose their ends. A test measures every label against
that width. The tab list moved into MainTabView.tabItems so the screenshot
preview draws the same bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hello link (SPEC §6.34) is https://flamenetmessenger.com/hi#<address>.
The address is in the fragment, which a browser never sends, so the page's
web server never receives it.

The address card gains "Hello link": the link, Copy link and Share in one
row, and what it does and does not tell anybody. The QR code now holds the
hello link rather than flamenet:<address>?u=<handle>, so a phone camera with
no Flamenet app lands on a page that explains it; the code no longer carries
the username.

The app registers the flamenetmessenger scheme. Opening
flamenetmessenger://hi#<address> hands the address to Home's search box, the
landing a scanned code has, and does nothing else: the person reads who it is
and decides, first contact goes the way it always does, and nothing is sent
until they send it. Signed out, the address waits for sign-in and the person
is told. A link that is not one, or holds no address, gets a plain message.
Home now also goes back to its list first, so the address is seen even when a
chat was open.

Parsing follows DESIGN decision 21: the https link (/hi or /es/hi), the
app link, and bare or flamenet:-prefixed addresses; the fragment decoded
once; fnid: plus 52 characters of the FNID alphabet; the host lowercased,
letters, digits, hyphens and dots, with at least one dot. ContactCard.decode
reads a pasted hello link as its address and refuses one that holds none,
and no longer takes a URL with an at-sign in it for an address. The scanner
accepts only what passes the strict form, says so when a code is not an
address, and keeps scanning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-FNMSGTodayPreview opens the Today tab, the directory or the address card
with no account, because simctl can launch the app but cannot sign in. The
Today screen is the real one: the store, the request, the signature check and
the copy on disk, against whatever relay is named on the command line, with a
folder of its own per state name so the app's own store is never touched.
DEBUG builds only.

TodayScreensUITests drives it: the page with a file, picking two cities, the
foot of the page, the same page with the relay gone, a relay that does not
offer the file, browsing and searching the directory, and the hello link card.
The Today cases need TEST_RUNNER_FN_TODAY_SERVER and
TEST_RUNNER_FN_TODAY_UNOFFERED pointing at relays on 127.0.0.1 and skip
without them; screenshots go to TEST_RUNNER_FN_TODAY_SHOTS when it is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /hi page checks the host as letters, digits, hyphens and dots with at
least one dot, which is DESIGN decision 21 as written, and caps the fragment
at 400 characters. The app also refused empty labels, a leading or trailing
hyphen and labels over 63 characters, so a link the page offered to open
could be refused by the app it opened. Now both apply the same rule, and the
app takes the same 400-character cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lone Pass button sat on a row of its own above Resign, and buttons that
appear together belong in one row. Tic-tac-toe's O was drawn in the channel
red, which is hard to read on the dark board, so both marks now have colours
of their own for each appearance, and the dots marking where a move can go
are smaller on the big tic-tac-toe cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picks are kept in the phone's settings, so a run inherited whatever the
run before it had picked, and tapping a city an earlier run had picked took
it away again. -FNMSGTodayNoPlaces clears them when the preview opens; the
suite passes it and checks each city is picked after its tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stickers in FlameNet's art, cards with sealed until, events with answers,
and games in a chat, with the games engine (DESIGN sections 1 to 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Today tab, the small-web directory shipped in the app, and hello links
(DESIGN sections 4 to 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two branches met in project.yml without a textual conflict: the
stickers and cards folders became folder references so the bundle keeps the
paths both apps name, while directory/directory-v1.json was still copied to
the bundle root by the Resources group. It now keeps its path too. The
loader looks in directory/ first and at the root second, so either layout
reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 0.32 release: stickers in FlameNet's art, cards with sealed until,
events with answers, games in a chat, the Today tab, the small-web
directory, and hello links. Build number follows the release, per the rule
above it in this file.

Checked: after a regenerate, project.pbxproj carries MARKETING_VERSION =
0.32.0 and CURRENT_PROJECT_VERSION = 32. No archive and no upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 29. The invitation already read "Sam invited you to play
chess" and came with a sound. The unread rule did not hold: every game
message that arrived as a new bubble added to unread, and a move whose
invitation was lost builds a new bubble from its log (rule 1), so a lost
invitation turned the first move into an unread one. Now only an invitation
adds to unread.

Tested between two inboxes on a stub relay: an invitation is unread and said
out loud; a first move whose invitation was dropped on the way builds the
board, is said quietly and adds nothing to unread; a later move adds nothing
either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 30. Chess, checkers and reversi turn round for the second
player; tic-tac-toe's cells keep their places on both phones. The board view
already did this inside a private property nothing could test. The rule and
the cell mapping are now GameBoardView.turnsRound and boardCell, tested for
every game and both sides, with chess and reversi as the control that a
turning game does turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 31, the owner's: while supervision is on, the Today tab is
not in the bar and its screen is not drawn even if it was the tab last open,
and the small-web directory, reached only from Today, goes with it. Both lead
straight out to the open web. It is not one of the gates a parent can
release; the supervision screen says so.

Tested beside the other supervision tests, which share the one process-wide
PIN: Today is in the bar without supervision, leaves when it is switched on,
stays hidden with every gate released, and comes back when it is switched
off. A source check confirms the screen sits behind the same rule and that
nothing but Today opens the directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 32. signOut now clears the kept Today file and the cities
picked for weather, and every way out of an account goes through it: the
sign-out screen, a device removed from the account, and a lost-device wipe.
A request already on its way when the phone is cleared is dropped when it
returns, rather than putting the file back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 32. Both places the scanner is opened, Home and Messages,
already handed on the address alone, and the card the scanner returns has no
place for the `?u=` an older card carries. A test now holds both: a code with
a username comes back as the same card without it, and every scanner call
site passes the address on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On iOS 26 and later `.searchable` puts its field at the foot of a sheet, on
a light ground, while the app styles every search field's text white for the
navy bar, so what somebody typed was white on near-white. Both sheets now put
the search box on a strip of the chrome under their own bar,
PortalSearchStrip, which carries on from the bar's gradient. The same thing
was seen and fixed on the 0.32 weather and send pickers.

Nothing in the app uses `.searchable` now, and a test keeps it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test waited until the stub relay had recorded the revoke request, then
checked that the app had cleared its note. The stub records the request
before it answers, and the app clears the note only once the answer reaches
it on the main actor, so on a busy host the check could land in between and
fail an app that was about to do the right thing. It now waits for the note
itself, the app's own record of the reply, and then checks the relay was
asked.

Measured with the two waits side by side while the main actor was kept busy
in 40 ms stretches, ten rounds each: the old wait failed 10 of 10 and the new
one passed 10 of 10. The committed test also ran 30 times in a row with the
machine at a load average over 160: 30 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 34: §8.11 is push-token registration, which older code
already cites, and the spec gains that section to match. The Today file is
§8.12. Every 0.32 citation of §8.11 for it changes; the push-token ones stay.
A test reads every Today file and the tab bar for a stray §8.11, with the
push-token citation as its control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 35. Only https://flamenetmessenger.com/hi#, /es/hi# and
flamenetmessenger://hi# are hello links: no query string, the path exactly
/hi or /es/hi in its own case with no trailing slash, the scheme and host in
any case. The app took /hi/# and /hi?ref=x#, which the other app and the
relay's page do not. The test that now refuses them failed on the old code
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 37. Until the invitee has moved, the invitation is still
open: the invitee can decline it and the starter can cancel it, and there is
no game yet to call a draw. This phone's own button already held back, but an
offer from the other phone in that window was taken. Now it is ignored. The
test failed on the old code first; its control, the same offer after the
invitee's first move, counts.

The other parts of decision 37 already held here: a gmove with no log or an
empty log and a gend with no log are dropped when read, and a draw-accept
counts only at the full known log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 38: the file comes from the home relay and nowhere else.
URLSession follows redirects by default, so a relay answering 302 sent the
request on to wherever it pointed. The session now has a delegate that
refuses every redirect. The 3xx comes back as the relay's own answer, which
counts as a relay without a file right now: the last good copy stays and the
page says so.

The test points a loopback relay's 302 at a second loopback server holding a
good file. It failed on the old code first, the second server receiving the
request; now the fetch reports 302 and the second server hears nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 38. The kid is lowercase hex, the form the app computes; the
app lowercased whatever arrived, so A9A94E74 was taken for a9a94e74. Both
versions, the envelope's and the body's, must be the JSON integer 1: the
lenient integer reader rounded, so 1.4 and 0.6 were taken for 1. A market
needs a name and its last figure, the symbol staying optional; an empty
figure was kept. Each test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking a story down (§6.35) removes my copy at once: the strip, the archive,
the decrypted bytes, the reactions and seen marks it carried, and any copy
still queued for somebody who could not be reached. Then an `unpost` goes to
the people the story went to and to my other devices, as silent control
traffic: `{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}`, or
`"set":"<set id>"` for a sequence, which comes down whole. A story now
records who it was sent to when it is posted; one posted before that, or
restored from a backup, goes to today's audience, or the group's roster
today. The relay has no way for an uploader to delete a blob, so the blob
waits for the retention purge.

A caption change is §6.15's edit with `st`, naming the story by the id
replies use, and a text story changes its words the same way. It is shown
as edited, refused when empty, ordered by the relay's time, sent silently,
and never notifies.

A receiver applies either only to a story it holds from the sender of the
envelope, and only a proven device of my own can take down or change a
story of mine. An unknown id is dropped. `set` is read as an object on a
story and as a string on an `unpost`, and a `set` or `sid` of any other
shape no longer costs the whole envelope. The backup marks an edited
caption with "edited".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The feed card drew every photo in a fixed 240pt band, filled and cropped,
so the top of a tall photo was cut off. The card now takes the photo's own
shape between half and one and a quarter times its width (180 to 451pt on
a 393pt iPhone, 204 to 510pt on a 440pt Pro Max, at most 560pt anywhere),
with plain bars beside or above a photo narrower or wider than that. The
composer previews the photo with the same view at the same width, so what
is posted is what friends see. Nothing was cropped on upload: ImagePrep and
VideoPrep both scale to fit.

My own story has Edit caption and Take down on the card, in the long-press
menu, in the full-screen viewer, and Take down in the archive. The take-down
asks first, in the words both apps use. Small thumbnails in the sequence
composer centre on the top third. `-FNMSGStoriesPreview` (debug builds only)
shows the feed, the confirmation, an edited caption, the composer and the
caption editor with fixture photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An `unpost` names exactly one of `sid` and `set`. One naming both is now
dropped, like one naming neither, instead of taking down both. A per-message
`secs` (§6.17) on an `unpost` or a caption edit plays no part; a test pins
both rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preview's bars were white on the white sheet, so a narrow photo did not
look the way the feed will draw it. Both composers now sit on the feed's
own page colour. The debug preview's fixture photos keep their labels
inside the picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 41. The host was lowercased with Unicode rules before it was
checked, so a Kelvin sign became a "k" and one host read as another. The
address is now checked to be ASCII first and lowercased by ASCII rules
alone; a pasted address gets the same check. The scanner read codes through
the paste path, which trims round the at-sign for somebody typing, so
"fnid:… @ relay" was taken; it now reads the code itself and refuses any
space in the address. A slash after the app link's hi was already refused.
The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At most five notes of 200 characters each (the app kept ten of 300). A
weather day whose date is not a real yyyy-MM-dd is dropped, where it was cut
to ten characters and kept. A fractional made refuses the file, where it was
rounded. A country is two ASCII letters or is ignored. A zone over 64
characters is ignored rather than cut, and a zone the phone does not know
shows the days as the file gives them, where they were cut by the phone's
own date. Market figures were already capped at 24, and lat and lon are not
read at all. Each new test failed on the old code first, the 24-character
one excepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 43, §6.10's "carrying ctl". ctl was read as a flag, so a
value it did not know, "1" written as a string among them, read as off and
the envelope was drawn: an unknown kind as the unsupported placeholder, and
a card or an event as a bubble although ctl wins over both. Now any value
other than 0, false or null is control. The other flags keep their own
reading. The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 45. days was clamped to 1 to 366 on arrival and the composer
sent up to 366; now above 31 shows as 31, below 1 as 1, and the composer
sends at most 31. The test failed on the old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 46. A §6.17 secs on a card, an event or a game set that
message's own timer; in 0.32 it is ignored, and the chat's disappearing
timer still applies to them. A text keeps its timer. The test failed on the
old code first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stories: take one down for everyone, fix its caption, and the whole photo,
never cut (DESIGN items 25 to 28).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As on Android: only the number 0, false and null leave an envelope as
anything but control. The strings "0", "false" and "null", and an empty
string, are values like any other, so an unknown kind carrying one is
dropped and a card carrying one is not drawn. iOS already read them this way
after 9d1612f5; this pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An edit carries its new wording in the text field, so warrantsAlert
counted it as something to see and the relay was asked to announce it:
the other phone got a second alert, sound and badge for a corrected
typo. §6.15 says an edit MUST NOT raise a notification, as §6.9 and
§6.19 say of reactions and pins.

warrantsAlert now answers no for any edit, reaction (to a message or a
story) and pin. Reactions, story reactions, pins and story caption edits
already went out silent, because their text is empty or they were
special-cased; now they do by rule.

The first two tests read the flag off the POST /e2e/messages request: a
one-to-one edit, reaction, reaction taken back, pin and pin taken down;
a group reaction, a private group reaction, a story reaction and a
story's words changing. An ordinary message and a group message are
the announced controls. The third pins the whole table of kinds as this
app sends them, including four that go out announced against §8.4 (a
profile picture, and a delivery key sent on its own as a repair, a
sealed group's key or a group's name), reported and not changed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A snoozed thread says "Snoozed, messages still arrive, this chat just
stops asking", and nothing on the way to a notification read the
snooze: it was stored and drawn, and every notice was posted as before.

postIncomingMessage takes a required snoozed flag and folds it into
mute, so a snoozed chat is held exactly as a muted one is, sound
included, and only while the snooze lasts. Every notice the inbox
raises passes it: messages, cards, events and their changes, and game
notices. Only a one-to-one chat can be snoozed, so group and federated
threads pass false. Reply reminders still fire, as the snoozed thread's
own line says, and the incoming-call banner is not changed.

The tests listen at both ends of the notification path, through a new
debug hook for what is handed to the system. In a snoozed chat a
message, a card, an event, its revision, a game invitation and a move
all arrive, all six reach the path, and none is posted; once the snooze
runs out, or "Wake now" is chosen, the chat is heard again; the same
person in a group, and somebody else one to one, are heard throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
§8.4 submits silent the traffic that is not a message to a person, and
names delivery keys among it; §6.10 counts avatar as control with them.
Both went out announced. A delivery key sent on its own, as a session
repair or carrying a sealed group's key or a group's name, parses as an
unsupported attachment, and a profile picture carries an attachment, so
warrantsAlert counted either as something to see. Creating or renaming
a group, adding a member, or changing a picture buzzed the other phones
with "New message" and raised the icon badge, with nothing new to read.

warrantsAlert now answers no for a delivery_key and for an avatar. A
delivery key riding ahead of a message is unchanged: it shares the
message's request and takes the message's flag. Android already sends
both silent.

The new test reads the flag off the request for a group's name at
creation and on a rename, a sealed group's key, a new profile picture,
and a repair sent after the receiving side lost its session. The table
test moves the four kinds from their reported list to the silent one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mute holds the banner the app posts for a call CallKit is not showing;
a snooze did not, so a snoozed chat still asked when its caller rang.
postIncomingCall takes a required snoozed flag and folds it into mute,
and the call manager passes whether the caller's chat is snoozed right
now, so the banner is held exactly as mute holds it and comes back when
the snooze ends. CallKit's own ringing is not touched.

The tests ring a call manager with a fresh offer, as the inbox hands
one over, and read a new debug hook for the banners that the settings,
mute and a snooze let through, ahead of the app-in-front check a test
host always fails. Snoozed, the offer rings and no banner gets through;
with the snooze run out, the same banner does. Each test hangs the call
up, so no ring or poll outlives it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copies content-kinds-v1, today-v1 and hello-link-v1, and the extended
backup-payload-v1 canonical set, from flamenet-e2e docs/spec-0-32 at
cbe2226 into Tests/Vectors, and adds a loader suite for each new file:

- content kinds: every written envelope parsed and written back by the
  app's encoders, byte for byte; every envelope through parse() and the
  route deliver() takes, checked field by field against the file.
- Today: TodayFile.verify on the sample, every case and the 512 KB edge;
  the sequence through TodayStore; If-None-Match through TodayPolicy.
- hello links: the link cases through HelloLink.address(inText:) and the
  scan cases through HelloLink.scannedAddress.

Test files only. The copied backup-payload-v1.json differs from the
flamenet-e2e commit in .e2e-ref, so the CI vector gate reads red here
until .e2e-ref moves to the spec commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 49. The test pass watched a sealed card stay sealed 25
seconds past its moment, through scrolling, opening only when the chat
was left and opened again. The bubble drew its face inside a
TimelineView whose content never read its context's date, and that
tick did not redraw it.

The face is now drawn for a time held in state. A task waits for the
moment the card opens, a quarter second past it, and sets the time, and
a state change always redraws. The task runs again whenever the card
comes back on screen, so one that opened while away is drawn open.

The UI test launches a DEBUG preview, a scrolling thread with one card
sealed for eight seconds, and touches nothing: the card has to open by
itself. Before the change it was still sealed after 20 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test pass found the picture composer's audience line barely
readable on the pale page and, once, the typed caption invisible. The
Stories tab opens its composers from a button in its header, and the
header hands its buttons white text and a white tint so they read on
the navy; a sheet inherits both from the button it is opened from, so
the composers drew near-white words on a light page.

All three composers (a picture, a sequence of pictures, and words) now
put the theme's ink and tint back at their root, and their secondary
lines use the theme's secondary text colour, which is chosen for the
page in light and in dark, rather than a shade of whatever was handed
down.

The UI test opens the picture and the words composer from a header
button exactly as the tab does, in light and in dark, and reads the
pixels of the audience line, the typed caption and the typed words:
each must reach WCAG's 4.5:1 between text and page. Before the change
they measured 1.05, 1.39 and 1.22 to 1. The DEBUG stories preview now
follows the appearance setting, as the app proper does, so the test can
ask for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 47 (owner). The app signed in only once the relay had
answered who the stored credential belonged to, so with no network at
launch a signed-in phone opened on the sign-in screen.

An account signed in on this phone now opens from what is on it: the
credential is presented without a request, the inbox is built from its
own keys and history without registering, and the phase is signed in,
so the chats and the saved Today page are there at once. Behind them the
app asks the relay who this is, waiting 1, 2, 4 seconds and so on up to
a minute between tries; the first answer finishes connecting (device
registration, delivery key) and fills in the account's names. Only a
relay that answers and refuses the credential goes back to sign-in, as
before. No stored credential is signed out, network or not.

Opening without the relay needs the account's user id, which the relay
used to supply; it is now kept beside the username at sign-in, sign-up
and every restore, and cleared at sign-out. An install updated from an
earlier build learns it on its first launch with a network.

The test plays the relay with a real socket on 127.0.0.1: closed at
launch, the app is signed in with an inbox within seconds; opened on the
same port, the app asks for the account and connects, still on the
chats. A relay answering 401 still means sign-in, and no credential is
signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN decision 48 sets one list of refusal reasons for both apps and
the vectors: unknown-version is a v present but not exactly the integer
1, in the envelope or the body. iOS called a body v of 1.4 or 1.5
bad-body, and an envelope v of 1.4, 1.0 or true malformed, because a v
that was not an exact integer failed the integer check before the
version was compared.

Presence and value are now asked apart. No v at all is still the shape
being wrong: malformed for the envelope, bad-body for the body. A JSON
null counts as no v.

The version test now expects unknown-version for 1.4, 1.0, 0.6, 1e0,
"1" and true in the body and 1.4, 1.0 and true in the envelope, plus the
two missing-v cases; it failed on every one before the change. The
malformed-file test's boolean envelope v moves to unknown-version with
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 0.32 shared vectors, loaded by the app's own code: content kinds,
the Today file, hello links and the refreshed backup payload (TEST-A).
Test-only. Merged after the Today refusal reasons, which its Today
loader expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UITestSafetyTests requires every UI suite that launches the app to be
able to skip or to stay on the device, and the two new suites did
neither. Both run a DEBUG preview needing no account, but the app still
probes its relay at start-up, and with no site stored that relay is the
production one. Each suite now points the app at a closed port on
127.0.0.1 with -FNMSGServer, and the safety test counts their two
preview flags as local, beside Files and the wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy merged from test/vectors-0-32 predates the spec's 86def06,
which took lat and lon out of the "cc, tz, lat and lon" case, and its
d07ed14 adds a case for an envelope version written as a string,
unknown-version, for 34 cases in all. Refreshed from the spec branch at
d07ed14; the loader's pinned count moves to 34. The other in-tree
vector files already match it byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no site stored, every launch probes its relay, and the default is
relay.flamenet.io: the unit-test host did so on every unit run, and so
did every UI suite that did not name a server, previews included.

A DEBUG test launch now resolves its site through
SiteConfig.forThisLaunch. A test launch is the unit-test host
(XCTestConfigurationFilePath) or the app launched by a UI suite, which
says so in FNMSG_TEST_LAUNCH. For one, the default site is a closed port
on 127.0.0.1, and a stored production site left by an earlier suite is
replaced by it as well, unless a UI launch named that very host with
-FNMSGServer, the way an opt-in live suite says it means it. The unit
host has no way to name one. Release builds and ordinary launches are
untouched: the shipped default is still relay.flamenet.io.

Every UI suite now builds the app through XCUIApplication.underTest(),
which sets FNMSG_TEST_LAUNCH. UITestSafetyTests pins it: no suite builds
an XCUIApplication directly; a test launch with nothing stored, or a
production site stored, resolves to loopback; production only when a
UI launch names it, never in the unit host; an ordinary launch is
unchanged; and this unit-test host itself resolves off production.

Suites that signed in on production by relying on the default must now
pass -FNMSGServer with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.e2e-ref moves to d07ed14 (docs/spec-0-32), the commit the copies in
Tests/Vectors were taken from. The unit-tests gate and vectors-head now
also compare content-kinds-v1, today-v1 and hello-link-v1, which the
0.32 loader suites read.

The pinned commit has to be on the remote before this pipeline runs, so
the spec branch is pushed first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
32 conflict hunks across 21 files. The two sides are the 0.32 feature branch
(cards, events, games, Today, the small-web directory, hello links, story
take-downs, stickers) and main (the Spanish catalog, attachment padding, the
stage 2-B request gate, bridge offers). Neither side is the answer anywhere
the conflict is about what the app does.

What the resolutions keep, and why:

Info.plist and project.yml: BOTH URL types. One claims `flamenet` for contact
links, the other `flamenetmessenger` for hello links. Taking either side alone
would have stopped the other kind of link opening the app at all.

CardScanner: git had already merged the controller to the union, with both the
injected `accepts` predicate and the new `onRefused` callback. The call sites
now match it. `accepts` is the only gate, because hard-coding `ScannedCode`
there would have refused every code the Devices screen scans. The address
sheet's default is `ScannedCode.card`, never `ContactCard.decode`, so an older
card's `?u=` username is still never trusted (DESIGN 32).

E2EInbox: `seedAccountStateFromDeviceIfNeeded()` moved to the factory rather
than into the release branch's new `connect()`. It reads this device's own
store and never the relay, and the offline launch path passes `connect: false`,
which would otherwise show every existing conversation as a request until the
relay answered. `deliverRemote` keeps both gates: main's `remoteMailShows` and
the release branch's control-traffic cases.

E2EEnvelopeRoute: bridgeOffer, rsvp and unpost all three, in the enum, in
`all`, and in the shape test.

MainTabView and PillTabBar: the extracted `PillTabItem` and `tabItems()` are
kept, with the `identifier` field put back and every title through
`String(localized:)`. `title` is a plain String, so the release branch's bare
literals would have left every tab English in every language.

ParentalControlsView: the release branch's fuller footer, written as ONE
literal. `Text` localizes a literal and not a `+` chain, so the concatenated
form would have taken that footer out of the catalog.

Eleven more places take the union with the localized string kept: the
attachment and sticker previews in E2EInbox, GroupsSection, BuddyListView,
ChatView, ChatViewModel and StoriesFeedView.

Also here: 35 spec references renumbered. `a3b05c3` in flamenet-e2e moved
anonymous reads and contact links to §8.13 and §8.14 to make room for the push
token and the Today file at §8.11 and §8.12, so every reference in this tree to
"8.11, anonymous reads" or "8.12, contact links" now pointed at the wrong
section. The Today and push references were already right and are untouched.

.e2e-ref is pinned to 4b4042f72880758ee2dea7713f7a2cb63fa12f73, the unified
spec, which is also what Android pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Scripts/check-spanish-catalog.py` reported 1466 keys in code against 1391 in
the catalog. The 79 without Spanish were all from the release branch: greeting
cards, events, games, the Today tab, the small-web directory, story
take-downs, and the two scanner refusal lines added by this merge. It now
reports 0 missing.

Terms follow docs/i18n/glossary-es.md and, where Android already shipped the
same label, Android's wording: Today is "Hoy", Card "Tarjeta", Event "Evento",
Game "Juego", story "historia", take down "retirar", directory "directorio",
bridge line "línea del puente". Every entry is `needs_review`, like the rest
of the es column: nothing ships until the owner or a Localization Lab
volunteer has read it.

The old parental-controls footer is deleted. The merge replaced it with the
longer text that also covers Today and the directory, so its key was dead.
"Attach photo" and "Search contacts" are still reported unused, as on main:
both reach the catalog through a `String` parameter the compiler does not
record, and "Search contacts" has three live call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ci: move CI to Gitea Actions
ci / vectors-head (pull_request) Successful in 11s
ci / secret-sweep (pull_request) Successful in 22s
ci / parity-scrub (pull_request) Failing after 13s
ci / unit-tests (pull_request) Failing after 30m2s
ci / ui-tests (pull_request) Failing after 30m2s
9099b6866c
Translates .gitlab-ci.yml to .gitea/workflows/ci.yml and deletes it.
Every gate is kept: secret sweep, unit tests with the vector pin check and
the 700-test floor, UI tests with the 10-ran floor, vectors-head (advisory),
parity scrub.

Xcode jobs run on the pauls-imac runner; the sweep, vectors-head and the
scrub run on mac1/mac2. Builds and tests only, no signing.

The Obfs4 submodule and the sibling clones now come from
git.extant2000.com/extant2000, as do the README links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
extant2000git added 1 commit 2026-10-14 16:22:30 +00:00
ci: 90 minutes for the iOS unit suite, 60 for the UI suite
ci / unit-tests (pull_request) Failing after 1h30m4s
ci / secret-sweep (pull_request) Successful in 15s
ci / vectors-head (pull_request) Successful in 7s
ci / parity-scrub (pull_request) Successful in 15s
ci / ui-tests (pull_request) Failing after 1h0m2s
50ce3c8610
The first run of this workflow died at exactly 30 minutes with "context
deadline exceeded", fifteen seconds past the vector gate and still inside
xcodebuild. 30 was chosen when the suite was 741 tests; the 0.32 merge makes
it 1289, and a cold `ci-build` compiles the app, its tests and the obfs4
package before any of them run.

pauls-imac has capacity 1, so ui-tests waits for unit-tests and then builds
from its own derived data; 60 covers that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

The unit suite is green. It was run on an M-series Mac at dc483d5e's parent 50ce3c86, the exact commit this PR carried:

Executed 1289 tests, with 0 failures (0 unexpected)
** TEST SUCCEEDED **

About 37 minutes end to end, including a cold build and a cold SPM resolve.

So the two CI failures on this PR were the runner, not the code. unit-tests on pauls-imac spent all 90 of its minutes inside xcodebuild and never printed an Executed line; the artifact it uploaded held 889 KB of build output and no test result. dc483d5e raises that limit to 180 and the UI job to 120, and says in the comment that if 180 is not enough either, the answer is an Apple Silicon runner rather than a bigger number.

The workspace on pauls-imac now holds a partial ci-build from the timed-out run, so the next run starts warm.

🤖 Generated with Claude Code

The unit suite is green. It was run on an M-series Mac at `dc483d5e`'s parent `50ce3c86`, the exact commit this PR carried: ``` Executed 1289 tests, with 0 failures (0 unexpected) ** TEST SUCCEEDED ** ``` About 37 minutes end to end, including a cold build and a cold SPM resolve. So the two CI failures on this PR were the runner, not the code. `unit-tests` on pauls-imac spent all 90 of its minutes inside xcodebuild and never printed an `Executed` line; the artifact it uploaded held 889 KB of build output and no test result. `dc483d5e` raises that limit to 180 and the UI job to 120, and says in the comment that if 180 is not enough either, the answer is an Apple Silicon runner rather than a bigger number. The workspace on pauls-imac now holds a partial `ci-build` from the timed-out run, so the next run starts warm. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Closing in favour of a PR whose head is the real branch. This one tracks refs/pull/319/head at 50ce3c86, and Gitea would not move that head to dc483d5e when the CI timeout fix was pushed, so it could only ever re-run the job that already timed out.

Everything in it is in merge/ios-0-32-unify at dc483d5e, which is 70 commits ahead of main and contains release/0-32-0 (4afa7640) whole, so PR 315 is closed with it.

The suite itself is green. Run on an M-series Mac at 50ce3c86:

Executed 1289 tests, with 0 failures (0 unexpected)
** TEST SUCCEEDED **

🤖 Generated with Claude Code

Closing in favour of a PR whose head is the real branch. This one tracks `refs/pull/319/head` at `50ce3c86`, and Gitea would not move that head to `dc483d5e` when the CI timeout fix was pushed, so it could only ever re-run the job that already timed out. Everything in it is in `merge/ios-0-32-unify` at `dc483d5e`, which is 70 commits ahead of main and contains `release/0-32-0` (`4afa7640`) whole, so PR 315 is closed with it. The suite itself is green. Run on an M-series Mac at `50ce3c86`: ``` Executed 1289 tests, with 0 failures (0 unexpected) ** TEST SUCCEEDED ** ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
extant2000git closed this pull request 2026-10-14 18:22:42 +00:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.