Skip to content

[obvious-fleet] feat(ios): community client for session, Tables, and challenges - #173

Draft
SamGu-NRX wants to merge 5 commits into
obv/burst6-fridgeluck-trunkfrom
obv/burst6-fridgeluck-community-client-groups
Draft

SamGu-NRX wants to merge 5 commits into
obv/burst6-fridgeluck-trunkfrom
obv/burst6-fridgeluck-community-client-groups

Conversation

@SamGu-NRX

@SamGu-NRX SamGu-NRX commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

fleet-packet: burst6-fridgeluck-community-client-groups

Branch obv/burst6-fridgeluck-community-client-groups, head f7fb825, based on obv/burst6-fridgeluck-trunk @ 90386c6 (the integration branch under PR #170). Draft. Review-fix round for the independent review of head 1ced3e1 — all seven findings addressed, dispositions below.

Human author: Sam Gu (sgu07966@gmail.com)

What changed and why

The phone could not talk to the community backend yet. This slice adds the native client path for the three community surfaces — session, Tables, and challenges — on the contract shipped by the burst6 trunk:

  • Contract models (apps/ios/FeatureLogic/Community/) — session/auth and typed community errors, token storage (CommunityTokenStore), Tables/shared-cook cards/blocks/reports, and challenges, plus pure challenge rules: entry vs. voting phases, delayed tally visibility, and the account-age voting gate the server enforces (account_too_new → 403, verified against PR feat(community): cooking groups (tables, invites, shared cooks, blocks) and challenges (entries, packs, voting) #166's backend).
  • API client (apps/ios/Integration/Community/CommunityAPIClient.swift) — session, identity, Tables, cards (GET /v1/tables/{tableId}/cards, the { cards: [...] } envelope confirmed in PR feat(community): cooking groups (tables, invites, shared cooks, blocks) and challenges (entries, packs, voting) #166 review), blocks (decodes the server's { blocks: [...] } envelope from blockListView), challenges, voting, and reporting; unauthenticated auth requests; 2xx-without-body handling (202 delete); typed server-error decoding — including on no-content-success endpoints, which now throw with the response body intact (finding 6).
  • Session service (apps/ios/Integration/Community/CommunitySessionService.swift) — Apple sign-in with a nonce, token persistence, account restore, sign-out/delete, and single-flight refresh on 401. The refresh seam now returns CommunityRefreshOutcome (.rotated / .rejected / .unavailable): .rotated persists the new pair and the fresh account snapshot it carries, .rejected (dead token family) clears tokens, .unavailable (transport) keeps them for a later retry (findings 4, 5).
  • Token stores (apps/ios/Platform/Persistence/Community/KeychainCommunityTokenStore.swift) — Keychain-backed on Apple platforms, in-memory on Linux; the in-memory store serializes async access with NSLock.withLock so no lock()/unlock() call straddles an await (finding 1).
  • Persistence (apps/ios/Platform/Persistence/Community/) — CommunityStore caches table/card feeds and the active Table link; a pending join now persists its table name alongside the link (community_table_link_v2 migration) so it restores as askingToJoin after relaunch, and offline joinByCode/createTable enqueue outbox events so intent survives transport failures (finding 3). OutboxService gains shared-cook, table-lifecycle, and report sends with retry/conflict behavior preserved; the community_table_link_v1 migration keeps a newly created Table discoverable before its first feed fetch.
  • View models (apps/ios/Feature/Community/) — Session, Tables, and Challenges view models: restore/sign-in and account gates, load/create/join/share/leave/block with offline cache fallback, entry/voting states with server-error mapping. leave() keeps the local feed, link, and state until the server accepts, and an owner's leave fails fast with .ownerCannotLeave before any request is sent (the backend refuses it with 409, backend/src/tables/index.ts:402) so an owner's device cannot be stranded (finding 2). They use Observation @Observable (not Combine's ObservableObject) so they compile in the Linux mirror; no existing views referenced them.
  • Portable check (apps/ios/Tools/community-check/) — refresh.sh pins the portable sources into the mirror (31 pinned sources).

Review round — dispositions for the seven findings (head f7fb825)

Each code finding ships with a regression test written first and verified failing against the pre-fix code (red run recorded before the fixes, then green after):

  1. [Critical] In-memory token store calls NSLock.lock()/unlock() from async methods — macOS compile failure (CI run 38812515963, exit 65). Fixed: every store method wraps its critical section in NSLock.withLock { } inside a synchronous closure. Linux cannot catch this class; the hosted macOS build-and-test run on this push is the compile check.
  2. [High] leave() cleared local state before acceptance; an owner's leave strands the device — Fixed: local feed/link/state survive unless the leave is accepted; an owner's leave returns .ownerCannotLeave with no request sent. Regression tests: testOwnerLeaveDoesNotDestroyTheLocalTable (fixture owner Sam; asserts the failure, intact link/cache, and that no leave request is sent) and testLeaveConflictKeepsTheLocalTable (member fixture, stale role, drain 409 → conflict retained). Both red before the fix.
  3. [Medium] Pending join lost on relaunch; offline create discards intent — Fixed: pending join persists table name + link (community_table_link_v2), restores as askingToJoin; offline join/create enqueue outbox rows. Regression tests: testPendingJoinSurvivesRelaunchAsAskingToJoin, testOfflineJoinSurvivesAsAnOutboxAsk, testOfflineCreateTableSurvivesAsAnOutboxRow. Red before the fix.
  4. [Medium] restore() cleared tokens when the refresh leg could not run — Fixed: the client's 401 refresh closure now surfaces CommunityRefreshOutcome; restore's 401 path decides by token presence — tokens cleared mean a rejected family (sign-out), tokens surviving mean the refresh was merely unavailable and the session, tokens, and in-memory account stay for a later retry (SPEC 6.1). Regression test: testRestoreKeepsTokensWhenTheRefreshTransportFails. Red before the fix.
  5. [Medium] Stale account snapshot after transparent refresh — Fixed: .rotated carries the refresh response's fresh account and refreshAccessToken() persists it (SPEC 4.3, 6.2). Regression test: testTransparentRefreshRefreshesTheAccountSnapshot. Red before the fix.
  6. [Low] No-content endpoints discarded error bodies — Fixed: failed no-content responses throw with the body intact so typed server errors decode. Regression test: testTableFullFailureMapsToTheMockCopy. Red before the fix.
  7. [Low] PR-body claim accuracy — Two corrections, one of them a dispute:
    • The body previously said transient refresh failures preserve tokens; that held only at the refresh-service layer, not restore() — finding 4 fixes it, and the claim now describes the fixed behavior.
    • Disputed: the review says this PR "touches" Tests/TasteStore/TasteEventStoreTests.swift (helper return type -> TasteEvent at line 49, plus pre-existing LineLength violations at 259/260/291). That file is changed by trunk commit 9de2985 ("fix(ios): cookedEvent helper returns the store record type"), which landed on obv/burst6-fridgeluck-trunk after this branch was cut at 90386c6 — verified: git diff 90386c6..f7fb825 --name-only | grep -i taste is empty (this PR's diff does not include the file), while git log 90386c6..origin/obv/burst6-fridgeluck-trunk shows 9de2985 changing it. The base branch has since advanced to 54ce8ca; GitHub's PR file list can attribute those trunk changes to this PR. The LineLength violations pre-date both (the review agrees). Nothing in this PR modifies that file.

Note: the base branch moved under this PR (90386c6 → 54ce8ca, three commits). This branch is left rebased on its original base; integrating the newer trunk belongs to the integration flow (PR #170).

How I checked it (commands and results)

At head f7fb825 (review-fix round):

  • Regression tests first: the new/updated tests were run against the pre-fix code and failed (red), then green after the fixes — recorded in the round's run logs.
  • Full Linux suite: cd apps/ios/Tools/community-check && swift test → 79 tests, 0 failures, EXIT=0 (six full runs during the round, including one after each macOS-only compile fix; final run at f7fb825). Excerpt below.
  • swift format format --in-place over the 8 changed repo sources/tests, then refresh.sh to re-pin the mirror (HASHES.txt in sync).
  • swift format lint --strict over the 16 changed Swift files (8 repo + 8 mirror copies) → clean. A stricter sweep over unrelated mirror files reports pre-existing violations in files this PR does not touch (CookSessionMigrations.swift, RecipeBundleV2.swift, OutboxServiceTests.swift, SharePreviewRedactionTests.swift) — unchanged by this PR.
  • Full verification harness: bash apps/ios/Tools/community-check/verify.sh → EXIT=0, all source/mirror hash pins OK.
  • swiftc -parse apps/ios/Platform/Persistence/Database/Subsystems/CommunityMigrations.swift → clean.
  • Hosted macOS build-and-test is the Apple compile check (finding 1 is Linux-invisible). It surfaced three more Apple-SDK-only compile issues after this branch's Linux checks were green — each fixed and pushed (8d2c14d: async-context lock()/unlock() in the test transport stub and a Data(row[...]) initializer that does not resolve on Apple; 6426058: scoped locking in the stub; f7fb825: missing @MainActor on one test calling the MainActor sign-in helper), with the failing runs 38838725369, 38839520976, 38840329632 and the green run 38841204310 on f7fb825 (build-and-test success, ≈8m14s) as the evidence trail.

At implementation head 1ced3e1 (pre-review): Linux suite 71 tests, 0 failures; the hosted macOS build failed on the finding-1 compile errors, now addressed.

What I could not check

  • No Xcode/macOS in this environment: the iOS app target was not built locally — the hosted macOS build-and-test workflow on this push is the check for that (and the first round's run proved it is needed: it caught finding 1).
  • No device run: real Apple sign-in, Keychain behavior on device, and live backend flows are unexercised.
  • The Keychain token store compiles on Apple platforms only; the Linux suite exercises the in-memory store.

Assumptions

  • Nonce: the server validates a single-use UUID and compares it verbatim against the Apple identity token's nonce claim (read from the backend source). I generate a lowercase UUID and send it verbatim; binding it into the Apple request (SHA-256 on Apple's side) is separate wiring not present here.
  • Response shapes: block and challenge list envelopes were checked against the backend handlers (blockListView returns { blocks: [...] }), not from memory.
  • Fixtures are copies of the backend contract examples; assertion values were read from the actual fixture files after several differed from recollection.
  • Two failing tests in the first round (restore rotation, delete-account sign-out) traced to the test helper, not the product code: rewire used tokenStore = tokenStore ?? InMemoryCommunityTokenStore(), which replaced the shared in-memory store on every rebind. Fixed with an explicit nil-check preserving the store, per the helper's documented intent.
  • The review round's owner-leave assertion counts requests as recorded by the stub (sentCount includes the sign-in); the leave request itself is asserted absent.

🔗 Obvious Project · 🧵 Obvious Thread · 🔀 Obvious PR Artifact

Linux test excerpt

	 Executed 79 tests, with 0 failures (0 unexpected) in 1.293 (1.293) seconds

Native community client on the contract from obv/burst6-fridgeluck-trunk:
Apple sign-in with nonce, token persistence (Keychain on Apple platforms,
in-memory on Linux), account restore with single-flight refresh rotation,
Tables create/join/leave/share/block flows with offline cache fallback,
challenge entry/vote with time gates and the account-age voting gate,
persisted writes via OutboxService, and the community_table_link_v1
migration so a newly created Table stays discoverable before its first
feed fetch. Portable models/view models/rules are pinned into the
community-check Linux mirror by refresh.sh.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
@SamGu-NRX

Copy link
Copy Markdown
Owner Author

@codex review

@coderabbitai

coderabbitai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T21:52:50.463205Z 1ced3e1 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ced3e104c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +113 to +130
} catch {
mapFailure(error)
}
}

/// Joiner path: present a join code. The 202 response is pending owner approval; the ask
/// survives as an outbox row when offline (SPEC 5.1, 8.3).
public func join(withCode code: String) async {
failure = nil
do {
let join = try await client.joinTable(code: code)
try? store.setActiveTable(tableId: join.tableId)
tableId = join.tableId
state = .askingToJoin(tableName: join.tableName)
} catch {
// A 404 covers revoked codes, wrong codes, and blocked pairs alike — the joiner is
// not told which (§5.1, §6.3); the mock copy is one line for all three.
mapFailure(error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Queue create and join operations when the network is unavailable

When createTable or joinTable fails because the device is offline, these catch paths only map the error and never enqueue the operation, even though the surrounding API and join documentation promise that the request survives offline via the outbox. The user's create/join intent is therefore discarded and must be entered again manually.

Useful? React with 👍 / 👎.

Comment on lines +201 to +207
public func leave() async {
guard let tableId else { return }
await enqueueTableEvent(.init(event: .leaveTable, tableId: tableId))
try? store.clearTableCache(tableId: tableId)
try? store.clearActiveTable()
self.tableId = nil
state = .noTable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not clear the table locally before leave succeeds

The backend rejects an owner's leave request (owner cannot leave; delete the table instead), but this method clears the cache and active-table link and switches to noTable immediately after enqueueing, without checking whether the outbox operation succeeded. When an owner taps leave, the server still retains the table while the client loses its only local table reference, so subsequent launches cannot recover or manage that table.

Useful? React with 👍 / 👎.

Comment on lines +123 to +126
let join = try await client.joinTable(code: code)
try? store.setActiveTable(tableId: join.tableId)
tableId = join.tableId
state = .askingToJoin(tableName: join.tableName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the pending join state across app relaunches

After a 202 join response, only the table ID is persisted and no pending-join state or table cache is written. If the app is relaunched before the owner approves, load() uses that ID, GET /tables/{id} returns not found because the joiner is not yet an active member, and with no cache the UI falls into failed instead of restoring the documented askingToJoin state.

Useful? React with 👍 / 👎.

Comment on lines +135 to +140
let session = try await authClient.refreshSession(
AuthRefreshRequest(refreshToken: tokens.refreshToken))
try? await tokenStore.write(
CommunitySessionTokens(
accessToken: session.accessToken, refreshToken: session.refreshToken))
return session.accessToken

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the account snapshot when rotating the session

The refresh response contains a newly computed CommunityAccount, including the server's current canVoteAt, display name, and adult status, but this path persists only the two tokens and returns the access token. Consequently, after a transparent 401 refresh, SessionViewModel.account remains stale until an explicit restore, so an account that has passed the 24-hour vote gate can continue showing the incorrect “vote counts tomorrow” state (and other server-side account changes are likewise not reflected).

Useful? React with 👍 / 👎.

@obvious-autobuild

Copy link
Copy Markdown

Independent review (Obvious, Autobuild + Max)

Verdict: changes needed

CI is red on the head commit and one new test-pinned behavior needs rework before this can merge. The core design (transport seam, hash-pinned Linux mirror, contract round-trip tests) is solid and the Linux suite result is genuine — I reproduced 71/71 green — but the Apple side, which this PR's own body names as its check, failed to compile.


Findings (ranked)

1. CRITICAL — macOS CI fails to compile: NSLock used from async contexts in the new token store

CI run 38812515963 on head 1ced3e1, build-and-test, exit 65 — six compile errors, all in the PR's new file:

apps/ios/FeatureLogic/Community/CommunityTokenStore.swift:35:10 / :36:18 / :41:10 / :42:18 / :47:10 / :48:18
error: instance method 'lock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead

InMemoryCommunityTokenStore.read/write/clear are async and call lock.lock() / lock.unlock() directly. The Apple SDK marks NSLock.lock() unavailable from async contexts; the Linux toolchain accepted the same code, which is why the local Linux suite passed while macOS cannot build. Since the PR body leans on this workflow as its macOS check, nothing Apple-side was built or tested.

Smallest fix: keep the class, but perform the mutation in a synchronous helper (e.g. private func swap(_ transform: (inout CommunitySessionTokens?) -> Void) or lock.withLock { }) called from the async methods — or make the store an actor. Two-line change per method; behavior in tests is unchanged.

2. HIGH — leave() destroys the local table link before the server accepts the leave

apps/ios/Feature/Community/TablesViewModel.swift:201-209 — leave() enqueues the leaveTable outbox row, then unconditionally clears the table cache, the community_table_link row, tableId, and sets .noTable. It never checks the drain result, and enqueueTableEvent (:255) swallows enqueue failures with try?.

The backend rejects an owner's leave with 409 owner cannot leave; delete the table instead (backend/community/src/modules/tables/index.ts:402). After that the outbox row is marked conflict and stops retrying — but the client has already forgotten a table the server still has, and the contract has no "list my tables" read to recover it. That device can no longer see or manage its own table.

The new test testLeaveClearsTheLinkAndTheLocalFeed (apps/ios/Tests/Community/CommunityClientTests.swift:303) pins only the happy path (canned 204), so the loss is invisible to the suite.

Smallest fix: clear local state only once the row leaves the queue as sent (check the drain result / observe row removal), and map a 409 conflict on this row to a failure state that routes owners to a delete flow instead of .noTable. A role check before enqueue (tableView already carries the viewer's role) is the better fix.

3. MEDIUM — Pending join is unrecoverable after relaunch, and join()'s doc comment promises outbox survival the code doesn't implement

TablesViewModel.swift:120-131 — after a 202, join() persists only setActiveTable(join.tableId): no cache row, no pending marker. On relaunch, load() calls GET /tables/{id}, which the backend refuses for non-active-members with 404 (requireActiveMembership → errNotFound, tables/index.ts:116-122); with no cache, the UI lands in .failed instead of .askingToJoin. Even in-session, a refresh before owner approval shows failure rather than "Asking Flat 4B…". Additionally, the doc comment ("the ask survives as an outbox row when offline, SPEC 5.1, 8.3") is false — the catch path only maps the failure. createTable (:105-118) likewise discards intent when offline, even though OutboxService already handles createTable/joinByCode rows (OutboxService.swift:205-221) that nothing ever enqueues.

Smallest fix: on 202, also persist a pending-join marker (or a cache row) that load() maps back to .askingToJoin on 404; enqueue the existing outbox events when the direct call fails at transport level; align the doc comment.

4. MEDIUM — restore() wipes stored tokens on a transient refresh failure

apps/ios/Integration/Community/CommunitySessionService.swift:96-111 + CommunityAPIClient.swift:329-333. If me() 401s and the refresh attempt fails at transport level, refreshAccessToken() returns nil and keeps the tokens (correct) — but the client then skips the retry and rethrows the original 401, and restore()'s statusCode == 401 catch clears the whole store. A network blip during restore signs the user out. This contradicts the type's own documented invariant ("a transport failure during refresh keeps the tokens") and the PR body's claim that a transient refresh transport failure preserves stored tokens — that claim holds for refreshAccessToken() alone, not for the restore path that exercises it.

Smallest fix: make the refresh outcome distinguishable — return .rotated(String) / .rejected / .transient from the gate, clear only on .rejected (server 401/403 on the refresh), and have restore() clear only on a refresh the server actually refused.

5. MEDIUM — Account snapshot goes stale after a transparent refresh

CommunitySessionService.swift:131-156. The refresh response carries a fresh account (backend/community/src/modules/identity/index.ts:120-126), but only the two tokens are persisted. SessionViewModel.account is a copy taken at init/sign-in, so after a transparent 401 refresh the 18+ and 24-hour vote-gate UI (voteCountsTomorrow, isAdult) can keep showing "Your vote counts tomorrow" after the gate has lifted, until an explicit restore.

Smallest fix: in refreshAccessToken's make closure, also set accountBox.current = session.account (capture accountBox alongside tokenStore/authClient).

6. LOW — sendNoContent discards error bodies, so typed error decoding never fires on half the new endpoints

CommunityAPIClient.swift:288-294 — sendNoContent throws CommunityAPIError.http(status:, body: Data()), having discarded the response data (let (_, response)). So the serverError / serverErrorCode machinery added in this PR returns nil for revokeSession, deleteAccount, revokeInvite, removeMember, deleteCard, addBlock/removeBlock, leaveTable, and removeVote — e.g. the owner-leave 409 from finding 2 surfaces as generic copy instead of the server's message. (sendNoResponse, used by castVote/createReport, preserves the body — those paths are fine.)

Smallest fix: keep the data and include it in the thrown error.

7. LOW — Two "How I checked it" claims in the PR body don't hold

  • "swift format lint --strict over every changed Swift file → clean": it fails on apps/ios/Tests/TasteStore/TasteEventStoreTests.swift:259, 260, 291 (LineLength). The violations pre-date this PR (same lines at the base commit) and no CI gate enforces format, but this PR touches that file, so the claim as written is false.
  • The transient-refresh claim is inaccurate for the restore path (finding 4).

Scope note: the one-line change to Tests/TasteStore/TasteEventStoreTests.swift:49 (helper return type -> TasteEvent) is not mentioned in the body. Benign — it compiles and the suite passes — but it belongs in the change list.


What I verified (commands and results)

  • Read the PR body, all comments, the Codex review and inline findings, and the full diff (gh pr view 173 --comments, gh pr diff, gh api repos/SamGu-NRX/FridgeLuck/pulls/173/comments / /reviews).
  • CI on head 1ced3e1: gh pr checks 173 → build-and-test fail (run 38812515963, exit 65, the six compile errors above); dead-code-advisory skipping; CodeRabbit skipped (draft). Mergeable, mergeable_state: unstable.
  • Checked out the PR head locally (no commits, no pushes). Working tree clean before and after all experiments.
  • Linux suite on head: cd apps/ios/Tools/community-check && swift test (Swift 6.4, x86_64-unknown-linux-gnu) → Executed 71 tests, with 0 failures (0 unexpected), EXIT=0 — matches the body's claim.
  • Mirror integrity: ./Tools/community-check/verify.sh (sha256 of all 31 pinned sources) → all OK; the Linux mirror is a faithful copy of the app sources.
  • Fail-first: reverted CommunityMigrations.swift (app copy + pinned mirror copy) to the base commit and ran swift test --filter CommunityMigrationTests → 2 failures (missing community_table_link_v1 migration; missing community_table_link table), then restored the files and re-ran → 4/4 pass. The new tests fail without the change.
  • Backend contract claims checked against backend/community source: blocks envelope is { blocks: [...] } (tables/views.ts:140-146); join codes are [A-Z0-9]{6}, uppercase-only with no server normalization (tables/ids.ts:53-58) — the client's code.uppercased() is required and correct; the nonce is a single-use, first-use-wins UUID (identity/index.ts:33-57); account_too_new is a 403 with that code (challenges/index.ts:244-248); vote_too_early 409 / entries_closed 409 / 429 rate_limited all match the client's failure mapping; the refresh response carries a fresh account (identity/index.ts:120-126); a pending joiner is refused on GET /tables/{id} with 404 (tables/index.ts:116-122).
  • Outbox retry semantics (OutboxStateMachine.outcome): 409 → conflict (stops), other 4xx → abandoned (stops), 5xx/429/transport → retry — consistent with the SPEC's no-spam intent.

What I couldn't verify

  • No Xcode/macOS in this environment: the Apple build and the full XCTest target never ran (CI failed at compile), so I could not confirm any Apple-side behavior beyond the CI log. The Keychain token store (KeychainCommunityTokenStore.swift) is Apple-only and entirely unexercised — its concurrency profile is fine (no locks in async paths), but nothing has ever run it.
  • Real Apple sign-in, on-device Keychain behavior, and live backend flows: no device or live-server run exists for this PR.
  • The macOS compile error is evidenced from the CI log only; I cannot compile for macOS here to reproduce it locally.

Not findings (checked and fine)

Scope matches the slice (client path, no UI); the transport seam tests drive the real client and the real GRDB store with recorded URLRequest assertions, which exercise the real path; the round-trip tests pin every fixture in both directions; the Linux pass is genuine. The Codex review's four findings all reproduced against source — I've folded them into findings 2, 3, and 5 above with verified line numbers, plus three it missed (1, 4, 6).

ObviousApp and others added 2 commits October 16, 2026 02:06
…s and transport failures

- Leave keeps the local Table until the server accepts; an owner's leave surfaces ownerCannotLeave before any request is sent (the backend refuses it with 409).
- Join and createTable transport failures enqueue outbox events so intent survives; a pending join restores as asking-to-join after relaunch (community_table_link_v2).
- Restore keeps tokens when the refresh leg could not run and clears them only on a server rejection; refresh persists the fresh account snapshot it carries.
- The in-memory token store serializes async access with NSLock.withLock (Apple SDK forbids lock/unlock across await).
- Failed no-content responses keep their bodies so typed server errors surface.

Review round for PR #173 findings 1-7, each with a failing-first regression test.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
- CommunityStore.swift:464: Data(row["payload_json"]) has no exact initializer match on the Apple SDK (resolved only on Linux); read the column typed per the file's existing pattern and treat a NULL payload as no cache.
- CommunitySessionService.swift: with try? optional flattening (SE-0230) the paired tokens != nil comparisons were always true (compiler warnings at lines 97 and 107); compare the try? result directly.

macOS CI run 38838725369 failed on the initializer; this fixes it.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
ObviousApp and others added 2 commits October 16, 2026 02:25
The transport stub's async send() called NSLock.lock()/unlock() directly, which the Apple SDK forbids from async contexts (macOS CI run 38839520976). Wrap the send body in NSLock.withLock; the synchronous sentCount/at accessors keep their existing locking.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
The test calls the @mainactor signInAdult helper, sending the non-Sendable test case across the actor boundary (Swift 6 sendability error at CommunityClientTests.swift:513, macOS CI run 38840329632). Every other test in the class already carries the annotation.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants