Skip to content

Inbox mobile: the Workspaces source in the iPhone app (M7) - #1797

Merged
backnotprop merged 4 commits into
mainfrom
feat/mobile-m7-workspaces
Oct 8, 2026
Merged

backnotprop merged 4 commits into
mainfrom
feat/mobile-m7-workspaces

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Mobile plan step M7: the Workspaces source in the iPhone app

The person signs in to Workspaces from the app through the real AuthKit page in the system browser sheet, and the app keeps a normal humanSession in its own cookie store. Workspaces then sits beside the computer's Inbox as a second source, one at a time, chosen from the title menu with each source's waiting count, and its comment threads are drawn with the same list, thread, question cards and reply bar. The phone picks, ticks a decision and sends through the answers door, the list updates live by ws ticket, and sign-out ends the session at Workspaces.

Records: PLAN.md step M7, brief briefs/M7-workspaces.md, the mobile decisions (7 to 10, 13), the iPhone record (1.1, 1.4, 1.5A, 9.1), the W0 transcripts and the W1 and W2 reviews. The contract coded against is Workspaces' live OpenAPI (https://staging.workspaces.plannotator.ai/v1/openapi.yaml); adr/implementation/inbox-mobile.md is unchanged (it leaves the Workspaces source to W0 to W2).

What changed

  • apps/ios/PlannotatorKit/Sources/PlannotatorKit/SourceClient.swift (new): the one protocol a shown source fills. InboxClient conforms as it is. M1 had no protocol; this is it.
  • WorkspacesClient.swift (new): the doors the phone uses. The sign-in door's phone arm (/auth/desktop/login, POST /auth/desktop/session with the PKCE verifier, POST /auth/desktop/logout), GET /v1/me, the device door PUT /v1/me/devices/{deviceId}, POST /v1/ws-ticket and the change-channel socket, and a document's text. Cookies live in the app's own store (not Safari's; the sheet is ephemeral). Every response may rotate them, and a mutation echoes the csrf cookie as X-CSRF-Token. A 401 on a session call is signedOut.
  • WorkspacesSource.swift (new): Workspaces mapped onto the Inbox's wire models. A notification row with thread_key annotation:<id> is a list row, placed in the Inbox's six sections. The thread is getDocumentAnnotation with its replies and questions. A pick goes through POST .../answers with one Idempotency-Key per pick: the comment, question, revision and answer, hashed (pickKey). A retry of the same pick carries the same key, so W2 replays its first answer, and a pick whose answer is lost is retried once. Each new pick is on a new revision, so it gets a new key. Send is final: true with the words and the decision ticks; ticks are kept on the phone until the Send, because Workspaces stores none before. Opening a thread marks its rows seen and opened. Resolve is PATCH state. Live changes come from the personal channel plus each team's channel: a row on a team workspace is announced on the team channel (W0 2.15). A thread's id is <workspace>/<document>/<comment>, so each call addresses the comment from the id alone.
  • InboxClient.swift: InboxError gains workspacesUnreachable and signedOut, plus isUnreachable. Models.swift: InboxThread gains an optional document (Workspaces only; the Inbox never sends it).
  • App: App/WorkspacesAccount.swift (new) holds the account, the sign-in flow (ASWebAuthenticationSession), the simulator's loopback return and the push-token delegate. AppModel adds sign-in, sign-out, the switcher's counts and push registration. SourceSession takes either source (id, name, kind, any SourceClient). App/SourceWords.swift (new) gives each source its own words for the empty, unreachable, removed and gone states.
  • Screens:
    • Inbox/InboxTab.swift: 1.1's Workspaces tile, and 1.5A's switcher (a picker of sources, each with its subtitle, mark and checkmark, then Add a source). Add a source offers the computer's Inbox or Workspaces.
    • Settings/SettingsTab.swift: 9.1's Workspaces row.
    • Settings/WorkspacesScreen.swift (new): the account, its teams and Sign Out.
    • Thread/DocumentRow.swift (new): the comment's document and quote, opening its text natively.
    • InboxList, ThreadScreen and ReplyBar use the source's own words. There is no Delete for Workspaces. A teammate's reply shows their name.
  • project.yml, Info.plist, Plannotator/Workspaces.entitlements: the WORKSPACES_HOST build setting, the TestFlight configuration and the associated domain (see below). Assets.xcassets/WorkspacesIcon is the Workspaces app icon, the same image as staging's /apple-touch-icon.png.
  • Proof: PlannotatorUITests/WorkspacesProofTests.swift and scripts/workspaces-proof.ts (new). README and CLAUDE.md entries updated.

The build setting (owner item 9: the first App Store release is local only)

WORKSPACES_HOST decides whether a build has a Workspaces source at all. There is no runtime switch.

  • Debug and a new TestFlight configuration name staging.workspaces.plannotator.ai. They carry Plannotator/Workspaces.entitlements: applinks: and webcredentials: for that host, so a signed build on a phone receives the https sign-in return. This was added after the M8 lane's finding.
  • Release, the App Store build, leaves it empty: no Workspaces source and no associated domain.
  • WORKSPACES_ORIGIN (Info.plist WorkspacesOrigin) derives from the host.
  • A TestFlight build is xcodebuild archive -configuration TestFlight. Production is WORKSPACES_HOST=workspaces.plannotator.ai, which gives both the origin and the entitlement.
  • Checked:
    • Debug, TestFlight and Release all build for the simulator.
    • The processed entitlements carry the expanded staging host in Debug and TestFlight, and are absent in Release.
    • With the production host, both the origin and the entitlement follow it.
    • The simulator ignores the entitlement, and the M1 proof still passes on the Debug build.
    • A signed build also needs the Team ID in the server's association file (owner item 1).

Record sections made real

  • 1.1: the Workspaces tile ("Sign in with your account") under the computer's. Aligned (built/1.1-*.png beside record/1.1-*.png).
  • 1.4: the real AuthKit page in the system sheet ("Sign in", email, the four provider icons, Sign up), as the record's addendum and the brief say, not the drawn page. Haptic success on return.
  • 1.5A: the menu with each source, its line and waiting count ("Inbox · 1 waiting", "Stress stress-b Hot · 3 waiting"), the checkmark, the marks and Add a source. It opens from the inline title (deviation 1).
  • 9.1: Sources lists the computer and Workspaces (" · ", the status dot), then Add a source. The Notifications block is M5's.
  • 2.1B, 3.1, 3.2 and the Sent state, drawn from Workspaces, are M1's screens with the source's data.

Proofs and how to run them

  1. The staging proof (XCUITest WorkspacesProofTests, run by the harness):
    bun run --cwd apps/review build && bun run build:hook && bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --define '__CLI_VERSION__="0.0.0-dev"' --outfile .local/plannotator
    WORKSPACES_PROOF_EMAIL=... WORKSPACES_PROOF_PASSWORD=... WORKSPACES_PROOF_AGENT_KEY=... bun apps/ios/scripts/workspaces-proof.ts --binary .local/plannotator
    
    Passed on iPhone 17, iOS 26.5, simulator mobile-m7-<pid> (made and deleted by the harness): 1 test, 358 s. In order, it covers:
    • 1.1 with both tiles.
    • A forged return link (plannotator://signin?state=<not ours>&ticket=forged) is dropped: no alert, no sign-in.
    • The local Inbox is paired by typed address and code.
    • Add a source, then Workspaces: the real AuthKit page in the sheet, email, then password. The password is pasted from the simulator pasteboard, because XCTest logs typed text. Then back to the app, signed in (9.1).
    • The agent (an API key of the test account, over MCP workspaces.create_annotation) asks two questions in a comment that tags the person. The row lands live by ticket, with no pull.
    • The switcher shows both sources with their counts (1.5A). It switches to the computer and back.
    • The thread: the document row; a pick on each card; the decision switch on the first.
    • A retried pick replays: the first pick, sent again with the app's own session and the key the app derives, answers 200 with the first pick's revision. The same pick under another key answers 409 question_revision_conflict.
    • Words; Send.
    • The agent reads the reply through MCP workspaces.list_annotations (asked_by: me): "Go ahead." and "Answer: Yes", both questions sent, the first with a decision_id. The project's decisions list it.
    • The document's text opens natively. The list holds at the largest Dynamic Type size.
    • Sign out: the app's own store no longer holds the session cookie. The cookie the app held then answers 401 (229 s after sign-out, see finding 1).
    • Frames light and dark, and the recording: research/operational/mobile-proofs/M7-2026-10-08/ (built/ beside record/ 1.1, 1.4, 1.5A, 9.1; M7-sign-in-pick-send.mp4; proof-run.log, which holds no secret).
    • The account is a synthetic staging person (user-001 of the stress harness's stress-b), signed in for the lane by the Workspaces repo's own scripts/stress/lib/login.mjs. The agent's key was minted from that person for the lane (mobile-m7 proof agent) and revoked after; never the owner's. Each run makes its own workspace and deletes it.
  2. The Inbox proof, unchanged: bun apps/ios/scripts/proof.ts --binary .local/plannotator passed (the M1 flow, 250 s). WorkspacesProofTests skips itself there.
  3. PlannotatorKit: swift test in apps/ios/PlannotatorKit, 12 of 12.

CI: local only. CI has no Workspaces secrets, and plannotator-ops and the owner have not agreed to add them. The CI iPhone job runs the Inbox proof, and the Workspaces test skips there.

Still needs a signed build or the device:

  • The https return through the associated domain. The entitlement is in the app; it waits on the Team ID (owner item 1) for signing and for staging's association file. The simulator build returns through the loopback arm instead (deviation 2).
  • A real APNs push for Workspaces (W2's item 1, M5).
  • Camera pairing (M1's).

Gates run

  • bun run typecheck: clean.
  • bun run check:release-version: consistent.
  • xcodebuild build-for-testing for the iOS Simulator: succeeded.
  • swift test: passed.
  • gitleaks git . --config .gitleaks.toml --log-opts="origin/main..HEAD": no leaks.
  • Rebased onto 5d39c9812 (M8 merged). The conflicts were CLAUDE.md, the README and the generated project; the project was regenerated. M8's RELEASE.md and APP-REVIEW.md now follow WORKSPACES_HOST: the TestFlight archive is -configuration TestFlight, with an entitlement check. The Swift is the code the staging proof and the M1 flow ran on (both green after the review items).
  • bun test was not run, because no TypeScript package changed. gen-colors --check is untouched.

What it leaves out

From the review (follow-ups, not in this PR):

  • Kit tests for the sign-in return parse, the section mapping and the row mapping.
  • The forged-return case while a sign-in is pending (the reviewer's scratch run showed it holds).
  • A logout that could not reach Workspaces leaves the server session and its device row until WorkOS expires them.
  • Decision ticks are not cleared on sign-out.
  • The menu's laptop mark has no grey tile.
  • The 1.4 addendum in the record (the overseer's).

Also left out:

  • Annotation on Workspaces documents (the brief leaves it out).
  • The local-to-Workspaces sync (decision 13, later).
  • The Decisions tab for Workspaces: the tab is M3's (PR 1791, not on main). The tick records through the Send, and the proof reads the project's decisions through the API. Plugging Workspaces into M3's tab is a follow-up once it lands.
  • The decision sheet's statement (5.1, M3): the server drafts the decision text.
  • The surface for Workspaces documents (M2 not on main): a native row opens a Markdown document's text, and other kinds say to read them in Workspaces.
  • The notification permission ask and the service extension (M5). The registration code is here; it runs only once iOS says notifications are allowed.
  • Account deletion (DELETE /v1/me is planned, not live; App Review 5.1.1(v), M8).

Deviations from the record, the brief or the contract, with the reason

  1. Every Inbox screen loses the drawn large title (1.1, 1.5A, 2.1B and the rest). The Inbox tab's title is now inline at the top: "Inbox ⌄", with the source under it in small type. The record draws a large "Inbox" with a chevron beside it. On iOS 26 the system draws the title menu's chevron only on an inline title. With M1's large title the switcher could not be opened until the list scrolled, and a short list never scrolls. The proof found this: the menu never opened with two sources and seven rows. .toolbarTitleDisplayMode(.inline) keeps "Inbox ⌄" and the source under it always reachable. A custom largeTitle toolbar item was tried; it rendered centred and was invisible to VoiceOver.
  2. The simulator's sign-in return. The brief asks for the spike's custom-scheme fallback, but W1 accepts no custom-scheme return; it accepts only the https mobile shape and the desktop loopback shape. A simulator build (#if targetEnvironment(simulator)) therefore uses the loopback shape. A one-request listener on 127.0.0.1, for this sign-in's nonce path only, answers with a redirect to the sheet's plannotator://signin, so the sheet completes as the spike proved. A device build uses the https callback. Both arms redeem only a return carrying the sign-in's own state. A return arriving from outside (onOpenURL, a universal link) finishes only the sign-in in progress with that state, and is otherwise dropped (proved).
  3. The list holds comment threads only. Rows about a whole document, an approval round, a decision or a guide have no thread to answer on a phone, so they are not shown, and the waiting count leaves them out.
  4. No Delete for Workspaces; Resolve resolves the comment for the team. A comment is the team's.
  5. A Send with words and no picks is a plain reply (POST .../replies): the answers door needs at least one question. That door takes no Idempotency-Key, so a lost answer there is settled the way M1 settles one, by re-reading the thread.
  6. 9.1's Workspaces detail screen (account, teams, Sign Out) is not drawn. 9.2 is the computer's.
  7. A Sent row's mark is the asking agent's (from the row's actors); its newest event is the person's own Send.
  8. Push is registration only (the brief's fallback while M5 is not on main). PUT /v1/me/devices/{dev_ULID} runs when iOS hands a token after notifications are allowed (sandbox in Debug, production otherwise). It is not proved in the simulator: there is no aps-environment entitlement without the Team ID, and the ask is M5's switch.

Findings outside the app

  1. Workspaces sign-out does not end the cookie at once. POST /auth/desktop/logout answers 204 and revokes the WorkOS session, but the sealed cookie keeps answering 200 until its access token expires. That took 307 s in a curl check and 227 to 229 s in the proof, because the session check verifies the token without calling WorkOS (by design). The app forgets the cookie at once, so the phone is signed out. A copied cookie would work for up to about five minutes. This is for the W1 owner and the auth board, not this PR.
  2. The 1.4 render versus the real page: the record's addendum already rules for the real AuthKit page. On staging the page is WorkOS's own, with the Apple button on WorkOS's demo credentials (W0 section 6, an owner item before M8).

Overlap with lanes in flight

  • M2 (PR 1785) and M4 (PR 1790): ThreadScreen.swift, ReplyBar.swift and project.yml. Their ProofSupport.swift helpers are duplicated privately in WorkspacesProofTests (about 30 lines) until one lands.
  • M3 (PR 1791): SourceSession.swift (here source becomes id, name and kind), InboxTab.swift, InboxList.swift, and its Decisions tab, which the Workspaces source should plug into.
  • M5: the app delegate and push token (WorkspacesAccount.swift holds a minimal AppDelegate; M5's will absorb it), and 9.1's Notifications block.

Limits

None. The sign-in door's existing 1800 s handshake and 120 s ticket windows are the server's own.

Sign in to Workspaces in the system browser sheet on the sign-in door's
phone arm, keep the normal session in the app's own cookie store, and
draw Workspaces' comment threads with the same list, thread, cards and
reply bar as the local Inbox: a second SourceClient beside the device
door's. The title menu switches between sources with each one's waiting
count; picks and Send go through the answers door with Idempotency-Key,
the decision tick rides the Send, live updates come by ws ticket, and
sign-out ends the session at Workspaces. Behind WORKSPACES_ORIGIN (Debug
and TestFlight on, the App Store build off).

Proof: apps/ios/scripts/workspaces-proof.ts against staging with a test
account (local only).
…M7, M8 finding)

Plannotator/Workspaces.entitlements claims applinks: and webcredentials:
for $(WORKSPACES_HOST), so a signed build on a phone receives the https
return (ASWebAuthenticationSession needs webcredentials for an https
callback). WORKSPACES_HOST is now the one switch: Debug and a new
TestFlight configuration name staging and carry the entitlement; Release,
the App Store build, leaves it empty, with no Workspaces source and no
entitlement. WORKSPACES_ORIGIN derives from it. Production is
WORKSPACES_HOST=workspaces.plannotator.ai.
… review item 1)

The key hashes the comment, question, revision and answer, so a retry of
the same pick carries the same key and W2 replays its first answer; a
pick whose answer was lost is retried once. The staging proof sends the
phone's pick again with the app's session and derived key (200, the
first result) and under another key (409).
@backnotprop
backnotprop force-pushed the feat/mobile-m7-workspaces branch from b693d9a to 56d35d6 Compare October 8, 2026 17:39
@backnotprop
backnotprop merged commit 3dedb6c into main Oct 8, 2026
29 checks passed
@backnotprop
backnotprop deleted the feat/mobile-m7-workspaces branch October 8, 2026 18:07
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.

1 participant