Calls reach grain-serve by its own method names
MergedThis moves every call to grain-serve's social.grain.* names. It adds the typed preferences read-modify-write, the PDS-side gallery and account deletes, the profile seed, the report proxy and reasonType, and the label-definitions cache. I found nothing that must change.
- Preferences:
UserPreferencesdecodes and encodes the typed array, keepstheme, and drops kinds the app doesn't know. That matches the body's claim that only the four kinds grain-serve takes are sent. The lost-update race across devices is already listed under Shortcomings. - Deletes:
deleteGalleryremoves children first, spares photos that another gallery still uses, and goes inapplyWritescommits of at most 200.deleteAccountremoves onlysocial.grain.*collections, doesn't index, and then callsdeleteAccount. Pagination stops when a page is empty or has no cursor. - Profile seed: it runs detached after sign-in and logs failures without throwing. It puts with
swapRecordwhen a profile exists and creates atselfotherwise. - Report:
XRPCClient.proxy(for:)special-casescreateReport, so it carries the grainatproto-proxyheader even though it is acom.atproto.*method. - Report sheet:
ReportViewreadsLabelDefinitionsCachefrom the environment, andGrainAppprovides it.
I couldn't check the mirrored GOING_SQL and reasonType table against grain-serve's source, and the body says the changes weren't tried against a running grain-serve.
I read the diffs of RecordEndpoints, FeedEndpoints, ProfileSeed, XRPCClient, ModerationEndpoints, PushManager, AuthManager, NotificationEndpoints, GalleryService, LabelDefinitionsCache, NotificationsViewModel and ReportView. I did not read the remaining endpoint renames, the call-site edits in views and view models, the tests, DEVELOPMENT.md, justfile or project.yml. They are mostly mechanical renames and test updates, and the body says the tests were run.
Carried forward: the change this pull makes at a26a1206582bb507d06787019e55bac32113d234 is the one reviewed at e1e077c8e66b02a78411a493e69b1593385d884a, so that review stands. What is new comes from its base.

What changes
Every call names a method grain-serve serves. The app's 24
social.grain.unspecced.*methods (25 call sites, typeahead twice) take their names from the map in grain'slexicons/README.md, and its 8dev.hatk.*methods (12 call sites) their replacements. Nothing in the app, its tests or its strings names hatk now. No call sendsviewer: grain-serve reads it from the call's own auth.Feeds are
social.grain.feed.getFeed, asked for by URI:at://<grain-serve's DID>/social.grain.feed.generator/<name>, asdescribeFeedslists them. The other parameters are unchanged.Preferences are
social.grain.actor.getPreferencesandputPreferences, typed (social.grain.actor.defs#preferences) and written back whole. Changing one kind reads the set, changes that kind and writes it all back, so the other kinds stay as they were, the web'sthemePrefamong them. Only the four kinds grain-serve takes are sent.Notifications are
listNotifications,getUnreadCount(wherecountOnlywas) andupdateSeen(whereputPreference lastSeenNotificationswas). The list loads with the count beside it, and falls back to counting the page'sisRead: falseif the count fails.Push is
social.grain.notification.registerPushandunregisterPush, naming grain-serve's DID asserviceDid,iosand the bundle id.Reports are
com.atproto.moderation.createReport. ThereasonTypeis the one grain-serve files under the chosen label, from the same table the web uses (REPORT_REASONS). The call carriesatproto-proxy: <grain DID>#grain_appview, which the rpc scope already covers, so the PDS forwards it to grain and not to its own moderation service.Records are read with
com.atproto.repo.getRecordat the account's PDS. Its answer carriesvalue; the profile editor reads the avatar from it.Deleting a gallery happens at the PDS, since grain-serve's
deleteGalleryis the web's alone. It mirrors grain-serve'sGOING_SQL: the gallery's items, the photos only it places and their exif, and the account's own favorites and comments on it, then the gallery. They go children first inapplyWritescommits of at most 200, and each commit is indexed.Deleting the account deletes every record in each
social.grain.*collectiondescribeRepolists, at the PDS, without indexing. It then callssocial.grain.server.deleteAccount {did}, which forgets the account once the PDS lists none.A grain profile is seeded from Bluesky's after sign-in, as grain-serve seeds one for the web (
login/profile.rs), which never sees the app's sign-in. It runs in the background (ProfileSeed) and never blocks or fails the sign-in. Ifsocial.grain.actor.profile/selfis missing or has nodisplayName, the app readsapp.bsky.actor.profile/selfand writes a grain profile with itsdisplayName,descriptionandavatar(the blob ref as is), keeping anycreatedAt. An existing record is replaced withputRecord, swapped on the CID that was read; otherwisecreateRecordmakes one at rkeyself. The write is then indexed through the proxy.Label definitions come from
social.grain.moderation.describeLabels, read once through the proxy intoLabelDefinitionsCache(policies.labelValueDefinitions). They are kept inUserDefaultsfor the next launch. The copy the app carries (LabelDefinition.all, as the web'sLABEL_DEFINITIONS) is only the offline fallback: the cache holds it until the first answer arrives, and keeps it when the call fails. The report sheet lists the cache's definitions.Why
grain-serve serves
social.grain.*only, so hatk's names stop working at cutover. Each replacement follows grain-serve's lexicons and handlers incrates/grain-serve/src/onfeature/rust. Where grain-serve refuses a service-token caller (deleteGallery) or works by a different contract (deleteAccount), the app does what the README says the apps do: it deletes at its own PDS first.How it was checked
Run on the iPhone 17 Pro Max simulator, one run at a time: the test classes this changes, plus every class that reads the fixtures it changes. That is
EndpointContractTests,GalleryServiceTests,GalleryDeleteTests,GalleryPublishTests,NotificationsViewModelTests,NotificationsListRenderTests,FeedPreferencesViewModelTests,LabelDefinitionsCacheTests,LabelResolutionTests,NetworkSideEffectTests,XRPCClientTests,PDSCallTests,StoryViewSyncTestsandProfileDetailViewModelTests, and 22 render and routing classes overFixtures.routes: 447 tests, all passing. The whole target builds. The full suite was not run, and the repo has no CI.New tests cover the feed URI, the typed preference round trip (other kinds and the theme kept), the report's
reasonTypeand proxy,getRecord's parameters, the gallery delete (what goes and what stays, one commit, indexed), the account delete (grain collections only, thendeleteAccount, nothing indexed), the unread count,updateSeenand the push device.ProfileSeedTestscovers the profile seed: a named profile is left alone, no Bluesky profile means no write, create atselfversus put withswapRecord,createdAtkept, the avatar passed as is, and a failed write staying inside the background run.ProfileSeedTests,OAuthSignInTests,AuthManagerAccountTestsandEndpointContractTestswere run again on the simulator after it (86 tests, passing).LabelDefinitionsCacheTestscovers the label switch: it starts on the bundled copy, readsdescribeLabelsonce, keeps the result for the next launch, and on a failure keeps the copy and asks again. After that change,LabelDefinitionsCacheTests,LabelResolutionTests,EndpointContractTests,ProfileSeedTests,ListViewRenderTests(the report sheet) and 8 render classes were run again (194 tests, passing).Not tried against a running grain-serve.
Shortcomings
Gaps, with no grain-serve method for a service-token caller:
social.grain.gallery.deleteGalleryrefuses a service token, so the app works out at the PDS what goes with a gallery. That means listing its gallery items, exif, favorites and comments, which takes many pages for a large account. If the PDS's records differ from what grain-serve indexed, the two can disagree about what goes.Also:
docs/plans/2026-03-29-grain-native-ios-design.mdstill lists hatk's methods. It is a dated design record and is left as written.Review
Start with
Grain/API/Endpoints/RecordEndpoints.swift(the two deletes) andFeedEndpoints.swift(the preference coding). Stacked on the sign-in pull,at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzbahapdzh2h.🤖 Generated with Claude Code