think
16px
820px

Obscura Mobile — Product & Engineering Plan

Status: brainstorm/plan, 2026-07-11. Decisions marked [D] are settled; items marked [?] are open.


1. Product thesis

Obscura Mobile is a companion app, not a port. It owns the moments where a phone beats a desk:

A push notification arrives → you review the document → you approve, reject, sign, or stamp it → you're done in under a minute, from anywhere.

Everything in the app serves that loop, plus first-class repository access (browse, search, view). Admin, authoring, and configuration stay on the web.

Top priorities (per product owner):
1. Signing (internal tier + official Mekari/PSrE tiers), e-Meterai, e-Stamp, and workflow approve/reject — with best-in-class UX. This is the app's reason to exist.
2. Repository traversal and document viewing.

[D] Explicit non-goals:
- No camera / document scanning. Capture is delegated to mature scanner apps; Obscura Mobile accepts their output only via OS share-sheet ("Share to Obscura" → upload + AI analyze). Even that is Phase 3, not core.
- No admin surfaces (RBAC, watermark policy, signing identity, Peruri ops, workflow designer).
- No correspondence authoring (TipTap letter editor stays web); correspondence approval/disposisi IS in scope — it's the workflow loop.
- No AI authoring flows initially; ask-archive chat is a later nice-to-have, not MVP.


2. Tech stack [D — settled in prior discussion]

Layer Choice Notes
Framework React Native via Expo (dev-build workflow + EAS, not Expo Go) Shares TS skills + generated API client with web/
Location This repo, pnpm workspace monorepo mobile/ + packages/api-client + packages/shared
API client Generated from api/openapi.yaml (openapi-typescript + openapi-fetch) Same gen:api pipeline as web; one contract, one commit
Server state TanStack Query Same idioms as web; offline persistence later
Local state Zustand (tiny) Ceremony wizards, viewer state
Navigation Expo Router File-based, typed routes, deep links for free
PDF react-native-pdf to start; budget line for Nutrient/PSPDFKit if placement UX demands it The signing surface is where we'd pay
Design Carbon tokens (color/type/spacing) extracted into packages/shared, custom lean RN component kit, IBM Plex Carbon's RN port is dead — don't fight it
Secure storage expo-secure-store (Keychain/Keystore) + expo-local-authentication Refresh token behind biometric
Push APNs + FCM behind a provider-agnostic server layer See Phase 0
i18n i18next — reuse the existing web/src/i18n resources via packages/shared Indonesian-first, same keys where screens overlap
E2E Maestro flows on EAS builds Cheap to write, runs on real devices

Repo layout:

obscura/
  api/openapi.yaml          # the contract (unchanged role)
  go/                       # server
  web/                      # existing app
  mobile/                   # Expo app (this plan)
  packages/
    api-client/             # generated TS client  web + mobile consume
    shared/                 # design tokens, i18n resources, domain helpers

3. Information architecture

Four tabs. The left-most is the product.

┌─────────────────────────────────────────────┐
  [Inbox]   [Browse]   [Search]   [Account]  
└─────────────────────────────────────────────┘
  • Inbox — the unified action inbox (§4). Badge = pending actions. App opens here.
  • Browse — folder tree traversal → document list → document detail (§7).
  • Search — keyword + semantic search (/semantic/search), recent documents, filters (classification, tags, date).
  • Account — profile, saved signatures (/me/signatures), PSrE activation status, notification prefs, security (biometric toggle, sessions/devices), about.

Document Detail is the hub screen reachable from all tabs and from every push notification. It carries: preview, metadata, versions, signatures & meterai records, workflow tab, and the action bar.

Deep links: obscura://doc/{id}, obscura://task/{wfID}, obscura://envelope/{id} — every push payload carries one; cold-start lands directly on the right ceremony.


4. The Action Inbox (centerpiece)

One list that merges everything awaiting me:

Source API Card shows
Workflow tasks GET /workflows/inbox Doc title, requester, step, action required, age, note
My envelope slots my-sign state per doc (needs aggregation — §8) "Your turn to sign", OTP channel, other signers' progress
In-flight seal jobs GET /esign/seals/{jobID} (persisted locally + server list — §8) "OTP required" / "Processing" with live status

Design rules:

  • Grouped by urgency, not source: "Needs your action now" → "Waiting on others" → "Recently completed" (a short tail for closure/confidence).
  • No destructive swipe-to-reject. Swipe reveals Open and Approve… (which still goes through the review screen). A legal act never happens from a list row. Enterprise users fat-finger too.
  • Every card opens the same review screen (§5.1) — one muscle memory for all action types.
  • Pull-to-refresh + push-driven invalidation; badge counts on the tab and app icon.
  • Empty state that says something true: "Nothing needs you. 3 documents are waiting on other signers →".

5. Signing & stamping ceremonies — the UX contract

These flows share one skeleton so users learn it once:

Review    Configure    Confirm (biometric)    [OTP if official]    Result

Principles that apply to every ceremony:

  1. Review is mandatory and honest. Full-screen PDF, page indicator, and the act button stays disabled until the document has been opened (not necessarily fully scrolled — that's hostile for a 200-page attachment; we require opened + explicit checkbox for reject-notes and legal acts instead).
  2. Biometric confirm at the moment of the act (Face ID/fingerprint via expo-local-authentication). For the internal tier this is the confirm; for official tiers it gates the start of the provider ceremony. (It's UX + local security, not the legal act — the OTP remains the legal act for official; we never pretend otherwise.)
  3. No optimistic UI for legal acts. The success state renders only after the server confirms (201/200). In-progress = an honest progress state, resumable from the Inbox if the app is killed (server-backed job state already exists: my-sign survives refresh; seals/{jobID} is pollable — same property we lean on for mobile).
  4. Result screens teach the next step. After an official sign: "This document can now carry an e-Meterai →". After meterai: "Fully executed. View the audit trail →".
  5. Every failure is a Problem-details message with a next action, never a toast that evaporates. Burned-serial-class failures (meterai) get a distinct, sober screen: what happened, that admin has been notified, what to do.

5.1 Placement editor (shared by signature / meterai / e-stamp)

The single hardest mobile UX in the app; build it once, use it three times.

  • Render the page; user taps roughly where the mark goes → a placement box appears there (not a drag-from-toolbar dance — tap is the natural phone gesture).
  • Box supports drag + pinch-resize; meterai mode locks the box square (the QR specimen is square and providers square the rect to the shorter side, anchored lower-left — show the truth in the preview so the result never surprises).
  • Live preview inside the box: the user's saved signature image (/me/signatures), the meterai specimen, or the stamp graphic — at real aspect ratio.
  • Presets row: "Bottom left" (provider default), "Bottom right", "Last used" (persisted per user), and — later — "Suggested" (detect signature lines; nice-to-have, not MVP).
  • Page thumbnails strip to jump pages; placement is per-page, 1-based, converted to PDF points (origin bottom-left) exactly as SignVersionRequest.placement / meterai placement expect. All coordinate math lives in packages/shared with unit tests — we've been burned by Y-flips before (Peruri pass-through lesson).
  • Landscape support: placing a box on an A4 page in portrait on a 6" phone is cramped; rotating to landscape should feel great.

5.2 Internal-tier signature (in-house CA)

Simplest ceremony — make it feel premium since it's the free tier everyone touches first:

  1. Review → pick saved signature (or default appearance) → placement editor → biometric → POST /documents/{docID}/versions/{v}/sign (201 synchronous) → Result: new version created, "View signed document".
  2. Zero-config path: "Sign with defaults" one-tap variant for users who never care about placement.

5.3 Official tier — Mekari Global (OTP-in-app)

The OTP ceremony is where mobile beats web — the code arrives on the same device:

  1. Review → tier picker (§5.5) → placement → WhatsApp number confirm (prefilled from profile; editable) → biometric → POST …/sign with assurance: official, sign_kind: global → 202 otp_required.
  2. OTP sheet: 6-digit boxes, auto-advance, paste-aware; countdown to resend (/esign/seals/{jobID}/otp/resend); a "Open WhatsApp" shortcut button. (No OS autofill — WhatsApp codes don't hit the SMS autofill API; the paste-aware field + app-switch shortcut is the honest best.)
  3. Submit → 200 (landed) or 202 (still finalizing) → poll seals/{jobID} with a calm progress state ("Peruri is sealing your document…") → Result.
  4. Killed-app resilience: the pending job is re-surfaced in the Inbox from server state; tapping resumes the OTP sheet exactly where it was.

5.4 Multi-signer envelopes

  • Inbox card appears when it's my turn (my-sign: envelope id, OTP channel, turn state). Sequential signing means turn-taking is visible: an envelope progress strip (avatars: signed ✓ / you → / waiting) on the review screen.
  • My slot: send OTP on demand (/esign/envelopes/{id}/otp/send) → same OTP sheet → 200 = all done + sealed version, 202 = "You're done; waiting on N others" — set expectation, offer "notify me when complete" (push).
  • Requesting signatures from others (request-signature) is Phase 2+; viewing/canceling requests I sent (with per-signer resend) comes first — it's read-mostly and cheap.

5.5 Tier picker — progressive disclosure

Most users should never study PKI. One screen, three cards, plain language (ID-first copy):

  • Internal — "Perusahaan Anda menjamin. Cepat, tanpa OTP." (default)
  • Official — Global — "Tersertifikasi internasional. Kode OTP via WhatsApp."
  • Official — PSrE — "Tersertifikasi Kominfo (hukum Indonesia)." — shown locked with an explanation if psre/status says not activated, with an "Activate" path that hands off to provider eKYC in a browser sheet (/esign/psre/activate). Never dead-ends: locked state explains why and how.
  • Tier availability is permission-driven (esign.official) — hide, don't disable-without-explanation.
  • The no-downgrade rule (officially-signed docs can't take an internal signature) is enforced by showing only valid options with a one-line reason.

5.6 e-Meterai

The highest-stakes act in the app (each attempt burns a prepaid serial):

  1. Entry points: document action bar; the "next step" suggestion after an official sign; workflow post-approval hook (later).
  2. Precondition checklist rendered as a path, not an error: "① Officially signed ✓ ② Not yet stamped ✓ ③ Quota available (saldo: 99)". If ① fails → button becomes "Sign officially first →". Quota from /esign/meterai/quota (handle 409 = provider can't report; show "quota unknown" not an error).
  3. Placement editor in square-locked meterai mode with the real specimen preview.
  4. Deliberate confirm: this is the one ceremony with an extra explicit confirmation ("Affixing uses 1 e-Meterai (Rp10.000) and cannot be undone") + biometric. No double-tap-able button; disabled during flight; idempotency guard client-side.
  5. POST …/{version}/meterai → 201 → Result: stamped version, serial shown, saldo decremented, "View stamp record". Failure → the sober failure screen (§5, rule 5).

5.7 e-Stamp — [D] future Peruri-provided product (NDA/contract in progress)

Verified today: no e-stamp capability exists in the codebase (API, server, or web — every "estamp" grep hit is inside "timestamp"). Product owner confirms e-Stamp is a Peruri product; the commercial side (NDA, contract) is still in flight, so no integration work can start yet.

Planning consequences:
- It will ride the existing Peruri adapter + async-seal machinery (job → status poll → landed version), exactly like e-Meterai — so the mobile ceremony skeleton (§5) and the placement editor (§5.1, a stamp mode alongside the meterai square mode) are the right abstractions to build now. No mobile work is blocked on it.
- Backend integration starts only once Peruri API docs/sandbox access arrive; expect the same operational surfaces as e-Meterai (quota/saldo, failed-serial-style reconciliation) until proven otherwise.
- Slotted in Phase 3; if the contract closes early it can pull forward into late Phase 2 since the client-side pieces are shared.

5.8 Workflow approve / reject / return

  1. Inbox task card → review screen: document preview + workflow context panel (definition name, step X of Y, who acted before me with their notes — from workflows/{id} + history).
  2. Action bar: Approve / Reject / Return (+ overflow: Forward, Disposisi, Start sub-workflow — the API supports forward, dispose, sub-workflows; ship these in Phase 2, they're the Indonesian correspondence bread-and-butter).
  3. Reject and Return require a note (client-enforced even if server doesn't) — a rejection without a reason is organizational poison. Approve: note optional, one tap + biometric.
  4. POST /workflows/{wfID}/act → 204 → optimistically remove from inbox (this one can be optimistic — 409 conflict = someone else acted → honest "already handled by X" state, refresh).
  5. History timeline view (vertical stepper) on the workflow tab — mobile users constantly ask "where is this stuck?"; answering that well is free goodwill.
  6. Batch approve [?]: tempting for high-volume approvers, dangerous for review integrity. Position: not in MVP; revisit with a "reviewed N documents individually, approve all" pattern only if a real customer demands it.

6. Repository traversal & viewing

  • Browse: folder list with breadcrumbs (materialized paths make this cheap), pull-to-refresh, sort, classification tags and doc-status chips on rows (reuse the web's visual language: ClassificationTag, DocStatusTag equivalents in RN).
  • Document detail tabs: Preview / Info (metadata, tags, expiry, retention) / Versions / Signatures & Meterai / Workflow. Signature records reuse the per-signature card design from the web verifier (external signatures shown as "External signature — issuer X", never "Tampered").
  • Viewing uses the preview channel (GET …/versions/{v}/preview — serves PDFs inline, converts office docs server-side, respects read access) — not the download channel. This matters three ways: office formats view fine without mobile converters; DLP allow_download classes remain viewable-but-not-exportable; and the watermark-on-download pipeline isn't in the hot path of every scroll.
  • Export/share = the download channel (…/content), which enforces DLP and auto-applies the forensic watermark — the mobile share-sheet story is stronger than web (every file that leaves the app is attributed). Show a subtle "this copy is traceable to you" notice on export. DLP-blocked classes → explanatory sheet, not a 403 toast.
  • Viewer: page thumbnails, search-in-document (later), share/print via watermarked download only. Screenshot protection (FLAG_SECURE / iOS capture detection) as a classification-policy-driven option, not a blanket switch.
  • Offline: MVP = cache-on-view (encrypted, TTL). "Pin for offline" with policy control = Phase 3. Signing/acting offline: never — queue nothing that's a legal act.
  • Checkout/checkin, rename, move, upload: read-mostly stance for MVP — show checkout state, but editing operations stay web until there's demand.

7. Backend work required (Phase 0 — before any pixel)

STATUS: BUILT + e2e-verified 2026-07-11 (migration 00093). Items 1–4 below are done —
refresh/device auth (/auth/refresh, /me/devices*, device login param), the push
pipeline (FCM v1 + APNs senders, notify "push" channel, PUSH_* env config, dev log
sender), deep-linked notifications (link column), and GET /me/inbox. Real-provider
push delivery still needs Firebase/Apple credentials (config-only). Item 5 (HTTP Range)
deferred.

  1. Token lifecycle: refresh + short-lived access tokens (today: single session bearer). Refresh token in Keychain behind biometric. Device registry (name, platform, last-seen, revoke-from-web) — Account → Security on web gets a "Devices" section. Reuse the existing lockout/TOTP machinery untouched.
  2. Push: APNs/FCM token registration endpoint + fan-out from the existing notifications system (workflow task assigned, your-turn-to-sign, seal job landed/failed, envelope completed). Payloads carry deep links. Provider-agnostic layer given the on-prem posture ([?] self-hosted push relay for air-gapped customers — park it, note it in LICENSING/tiers).
  3. Inbox aggregation endpoint (GET /api/v1/me/inbox or similar): one call returning workflow tasks + pending envelope slots + in-flight seal jobs, with document titles joined in. Today my-sign is per-document — unusable for a cross-repo inbox without N+1. This is the single most important new endpoint for the app; web's Inbox page can adopt it too.
  4. TOTP enrollment on mobile: otpauth_url already returned — mobile renders QR and offers "open in authenticator" (the URL scheme) since scanning your own screen is impossible.
  5. Nice-to-have: HTTP Range support on preview/content streaming for large PDFs (check what the blob store already does).

Everything else already exists — the OpenAPI surface for sign/meterai/workflow/browse/search is complete and battle-tested from the web app. That's the payoff of API-first.


8. Security posture (enterprise checklist)

  • Refresh token: Keychain/Keystore, biometric-gated unwrap; access token in memory only.
  • Certificate pinning (policy-toggleable); root/jailbreak detection via freeRASP (report-only mode first).
  • Encrypted local cache for previews; wipe on logout/revoke; no document bytes in OS-level backups.
  • MDM AppConfig: pre-set server URL + policy flags (Intune/Workspace ONE) — sales will need this.
  • No third-party analytics SDKs that see document metadata. Crash reporting self-hosted (Sentry self-managed) [?].
  • Distribution: TestFlight/internal track for pilots → App Store + managed Google Play; Apple Business Manager custom apps for locked-down customers.

9. Phasing

STATUS 2026-07-11: Phase 0 ✅ (see §7). Phase 1 STARTED — the monorepo
restructure is done (root pnpm workspace; packages/api-client generated from the
spec, consumed by web + mobile; packages/shared with the Carbon-derived tokens and
the tested placement math; deploy/Dockerfile.web reworked and build-verified), and
the Expo SDK 57 app (mobile/) scaffolds the full skeleton: server onboarding →
device login → TOTP → refresh rotation, the Action Inbox over /me/inbox,
browse/search/document screens, workflow approve/reject/return with biometric
confirm + mandatory reject note, preview open, device management, push registration +
deep-link routing, id/en i18n.

UPDATE 2026-07-12: the placement editor + internal-tier sign ceremony are BUILT
and verified twice end-to-end — via the web-export Playwright harness AND on a
real Android 13 device (redroid on the dev box + Maestro; see mobile/e2e/README.md)
— with the signed PDF's /Rect matching the chosen placement to the point. Device
testing surfaced and fixed: a Kotlin-keyword package name, release-build cleartext
blocking, and nav-bar-covered buttons. Remaining in Phase 1: react-native-pdf page
rendering shows blank on redroid (wired + guarded; needs one check on a GPU device to
tell a Fabric bug from a redroid artifact) and the visual polish pass.

UPDATE 2026-07-13 (post-Simulator feedback): the document screen is now the
§6 design for real — four tabs (Overview / Details / Activity / AI) with the
egress split implemented exactly as §6 specifies: Preview rides the inline preview
channel under the scattered visible watermark; View / Download / Share ride the
protected /content channel (server-side ACL + DLP + forensic stego watermark),
gated in the UI by the classification's DLP policy with owner/admin break-glass —
mirroring the web and the server's own downloadAllowed. AI on device is live:
stored-enrichment summary + on-demand summarize, and Ask-this-document with
conversation continuation. Smart upload is live: document-picker → analyze-upload
(soft-fail) → editable AI summary/classification/tags → semantic folder suggestions +
folder search → create + version upload with analysis_id redemption (seeds
content_text + enrichment) → tags. E2E: new run-upload-ai.cjs suite (extract-sidecar
stub + mock chat provider) passes the whole loop, and the P1/P2/envelope suites pass
against the rebuilt screen. NOTE: expo-document-picker is a new native module —
re-run npx expo run:ios (pod install) after pulling.

UPDATE 2026-07-13 (round 3): Share = a revocable link, never bytes [D —
user-decided]
: the Share action now creates a 7-day view-mode share link (DLP
forward-gated server-side, revocable from the web) and hands the /s/{token} URL to
the OS share sheet — replacing the download-and-share-file behavior; watermark-on-
share-access is being fixed on the backend in parallel. Definition-workflow acting
works on mobile
: designer-driven tasks (from_definition) reaching the inbox now
act through act-definition from the same document action bar (the final approve
force-publishes, verified e2e — new run-share-defwf.cjs suite seeds org-unit →
position → assignment → definition → instance and drives the UI). Also fixed the
iOS 27 statusBarStyle deprecation spam (StatusBar is Android-only now).

UPDATE 2026-07-13 (round 4): iPhone feedback fixes — the viewer notice bar now
clears the home indicator (safe-area inset), and the Account language control is a
two-option selector with the active language highlighted, in React state (reading
the i18n singleton during render proved stale-by-one-frame) and persisted across
launches. Forward/disposisi shipped: a "Forward (disposisi)" action under the
act bar opens a people picker (search by name/email), sends the document to a peer
as a REVIEW task with the note as instruction — the forwarder's own task stays
pending, matching the engine's semantics; review tasks get their own inbox tag/icon.
Backend enabler: new GET /users/pickable (any authenticated principal — id/name/
email address book; the web signer picker should adopt it, its /admin/users source
breaks for non-admins) and GET /classifications documented in the spec; the upload
screen's classification chips now come from the registry instead of a fixed list.
E2E: the share+defwf suite now also covers forward (peer gets the review task, the
forwarder's task survives, approve still completes after).

UPDATE 2026-07-13 (round 5): live demo REDEPLOYED (update.sh → 195812b).
Navigation restructured [D — user-decided]: bottom tabs are now
Inbox · Archive · AI · Search; Account moved to a header avatar button on every
tab (it is settings, not a daily surface). The AI tab is Ask-the-Archive
(roadmap #5): a grounded chat over everything the caller may read — non-streaming
POST /ai/ask-archive with server-side conversation persistence via
conversation_id, cited sources rendered as tappable cards that open the document,
honest no-results state; the tab hides itself when ai+semantic aren't licensed
(href: null). E2E: upload suite extended — archive ask answers with sources
(mock chat + mock embeddings retrieve the uploaded doc) and the account header
button path + language persistence re-verified. Streaming deltas + conversation
list = the remaining ask-archive slice (roadmap #5).

UPDATE 2026-07-13 (round 6): Request-signature authoring shipped (roadmap #3):
a "Request signatures" action opens an authoring screen — ordered signer list
(colleagues from /users/pickable, or external parties by name/email/phone),
tier internal|Global (any external forces Global), parallel/sequential, deadline
chips — that POSTs request-signature. The server picks the machinery: internal →
workflow sign tasks; Global/external → a shared envelope. The Activity tab now lists
open envelope requests with per-signer status (internal signer names resolved via the
directory) and a Cancel that withdraws the request (failEnvelope, signer slots die).
E2E: new run-reqsign.cjs — internal+sequential (both signers tasked, order enforced
at sign time) and Global+parallel (envelope listed → cancelled → slots dead).

UPDATE 2026-07-13 (round 7, autopilot): six roadmap slices landed at once, all
e2e-verified (run-r7-features.cjs):
PSrE activation — the locked tier card gains "Activate eKYC" when the provider
supports it: activate → status poll → tier unlocks. Backend: /me's psre_enabled
was hardcoded false ("deferred until sandbox"); now wired to the provider status
through a 5-min cache (activate/status handlers refresh it), and the MockSealer
implements instant-eKYC activation so the whole loop tests without credentials.
Share-link management — Activity tab lists links (opens count, expiry, status)
with one-tap revoke. Per-signer invite resend for external envelope signers.
Semantic search — the Search tab adds a "By meaning" section (similarity %,
title-hit dedupe). Notification prefs — Account gains email on/off, cadence
(instant/daily/off), and per-category mute switches over /me/notification-prefs.
Conversation history — the AI tab lists stored conversations and resumes them
(server-held history via conversation_id). Screenshot protection — classified
(non-none) documents hard-block capture while the viewer/sign/meterai screens are
open (expo-screen-capture: FLAG_SECURE on Android, iOS 13+ block; NEW POD — re-run
npx expo run:ios). Batch upload — multi-pick switches the upload screen to
batch mode: per-file rows with live status, shared folder+classification, sequential
create+upload, AI enrichment via the server's async pipeline.

UPDATE 2026-07-13 (round 8, autopilot — the roadmap's long tail):
Offline pinning (MVP) — "Keep offline" on the document action row saves the
PREVIEW rendition into app-private (OS-encrypted) storage; pinned copies list at the
top of Archive and open with zero network; everything is wiped on sign-out. The
preview channel is deliberate: an offline copy is a reading copy, never an export —
those stay online, watermarked, ledgered. No legal acts offline, ever (§6 rule).
"Open with Obscura" — PDFs handed to the app by the OS (iOS document types +
open-in-place, Android VIEW intent) land in the smart-upload screen preselected,
AI analysis included (+native-intent.ts route bridge). True share-SHEET intake
(iOS share extension) stays deferred — it needs a separate native target.
Streaming Ask-the-Archive — answers now render word-by-word (SSE over
expo/fetch on native, browser fetch on web) with automatic fallback to the
non-streaming request when a proxy/env can't stream. NOTE: pinning, open-with, and
capture-block are native-surface features — Simulator/device smoke test pending.

UPDATE 2026-07-14 (round 9 — smoke-test feedback): all eight findings addressed,
e2e-verified (run-r9-features.cjs):
(1) meterai checklist icons are fixed-width (Ionicons circles, not glyph-sized pills);
(2) the placement ceremonies got a page stepper (react-native-pdf reports the
page count; the box follows across pages via placement.page) and the preview canvas
now carries the visible PREVIEW watermark; (3) e-Meterai no longer requires an
official signature
— soft warning + optional sign-first shortcut, matching web and
the server (a duty stamp is not signature-gated); (4) people-picker suggestions are
hard-bounded everywhere (2+ chars, max 5 rows); (5) Share opens a management
screen
— expiry choice (7d/30d/none) on create, existing links listed with status +
revoke; (6) signature draw + import: a finger-drawn SVG canvas captured to PNG
(native) and photo-library import converted to PNG (expo-image-manipulator; the
server accepts only PNG), managed from Account → My signatures and linked from the
sign ceremony — NEW PODS: react-native-svg, react-native-view-shot,
expo-image-picker, expo-image-manipulator → re-run npx expo run:ios;
(7) biometric app lock (Account → Security): Face ID/passcode on cold start and
on every return from background, opaque cover above the navigator so state survives;
(8) the document screen gained Related (links + AI suggestions with one-tap
linking) and Workflow (instances + expandable transition history) tabs.
Known Simulator caveat: the sign page "shows unwatermarked file" finding also covers
the fact that the preview CHANNEL bytes are unmarked by design — the visible overlay
now brands them; the invisible stego mark remains download-channel-only.

UPDATE 2026-07-14 (round 10 — smoke-test feedback #3, mostly AI UX):
(1) drawing a signature no longer scrolls the page — the canvas freezes the parent
ScrollView while a finger is down and refuses responder termination;
(2) Account screen bottom padding (sign-out was a hair from clipping);
(3) document tabs reordered [D]: Overview · Details · AI · Workflow · Related ·
Activity; (4) AI answers render markdown (tiny dependency-free renderer:
headings/lists/bold/italic/code — never crashes on weird input); (5) answer
references collapse to a "References (n)" toggle, collapsed by default;
(6) a conversation header bar with ← All conversations returns to the list;
(7) mid-conversation the big Ask button becomes a compact composer — bare
follow-up field + round send button; (8) incognito chats: a toggle on the list
state — nothing persists server-side (verified: 0 stored conversations after an
incognito ask), follow-up context travels as client-held history, indicator shown
in the conversation bar. Also fixed en passant: a conditional-hook bug in the AI
tab (useState declared after an early return). E2E: run-r10-features.cjs.

UPDATE 2026-07-14 (round 11 — AI chat structure): two AI-tab findings.
(1) The custom "All conversations" bar wasn't iOS's native back button — because the
conversation was tab STATE, not a route. Restructured: the AI tab is now the home
(past conversations + incognito toggle + new-question composer), and a conversation
opens as a pushed root route ai-chat — so it carries the native iOS back
button exactly like the document screens (router.push with q/id/incognito
params; the chat auto-sends the opening question on mount). (2) Rebuilt the thread as
a classic bubble chat: the user's turn is a tailed bubble on the right (interactive
fill, white text), the agent's answer a bordered tailed bubble on the left (markdown +
collapsed references), with a typing spinner in the left bubble while streaming. E2E
updated: the ask flow now spans /ai → /ai-chat; resume is via the home list + native
back; incognito zero-persistence still asserted.

UPDATE 2026-07-14 (round 12 — design/consistency polish pass): a systematic
audit (every screen + the kit) drove one shared vocabulary into components/ui.tsx,
replacing hand-rolled duplicates: SectionLabel (was ~24 <Body secondary>+spacer
idioms with drifting inter-section gaps), Row (one leading-icon · title · subtitle
· trailing-chevron list item — was ~9 variants with icon sizes 16/18/22/24 and
chevrons 14/16/18), Chip (one selectable pill — was 6 incompatible chip/segment
implementations), IconButton (was 5 copies of the trash/resend/add Pressable),
Callout (one left-accented notice — was two treatments), and StatusTag
(server enums → localized, tone-colored pills — was raw gray lowercase machine
strings). Genuine bugs fixed along the way: the placement box tint was a hardcoded
blue invisible on the dark theme (now theme-derived); Search had no bottom padding;
a folder pushed without a name showed a blank header; the auth screens used raw-px
spacers; Account's non-compact title left a stray gap; Tag/Loading used raw
pixel values instead of tokens. Document tabs reordered to Overview · Details · AI ·
Workflow · Related · Activity (round-10 decision, applied here). All testIDs and
behavior preserved. Verified: pnpm -r typecheck; expo export; the full e2e battery
(P1 sign, envelope, share+defwf, reqsign, upload-AI, r7/r9/r10 probes) re-run green
against the rebuilt harness; screenshots confirm the rendering. JS-only round.

Phase Scope Rough size
0 — Backend enablement Refresh tokens + device registry, push pipeline, me/inbox aggregation, deep-link payloads 2–3 wk, pure Go, ships behind the existing API version
1 — MVP Monorepo restructure; auth (login, TOTP, forced-change, biometric unlock); Action Inbox; Browse/Search; document detail + preview viewer; workflow approve/reject/return; internal-tier sign with placement editor; push + deep links 6–8 wk to a pilotable TestFlight build
2 — Official ceremonies Official Global OTP ceremony + e-Meterai BUILT & device-verified 2026-07-12 (d02ec41); ✅ envelope multi-signer slots (4d982d2); ✅ PSrE locked-tier card (ec13c40). Still open: PSrE eKYC activation handoff, forward/disposisi actions, signature-request viewing/authoring 4–5 wk
2.5 — Full daily usability BUILT & e2e-verified 2026-07-13: tabbed document detail (Overview/Details/Activity/AI), DLP-gated View/Download/Share over the protected content channel, in-app preview + full viewer, Ask-this-document + AI summary, smart upload (AI analysis, folder suggestions, folder search) this run
3 — Rounding out see the roadmap below demand-driven

Sequencing bet: Phase 1 ships the complete daily loop for the largest population (approvers + internal signers) and forces all the hard platform work (auth, push, viewer, placement editor) early. Phase 2 is mostly ceremony choreography on top of proven parts.

Roadmap — remaining features, sized for future runs (2026-07-13)

Items too big (or blocked) for a single run, in rough priority order:

  1. ~~Share links from mobile~~ ✅ one-tap create+share DONE 2026-07-13 (round 3, user-decided: links replace file egress on Share). Remaining slice: a manage/revoke list on mobile (web has it) — small run, demand-driven. The shared/{token} watermark gap is being fixed on the backend in parallel.
  2. ~~Forward / disposisi workflow actions~~ ✅ DONE 2026-07-13 (round 4): people picker over the new /users/pickable, review-task semantics (forwarder's task stays), inbox review tag. Remaining slice: the reviewer's act surface is the standard approve/reject bar — a dedicated "submit review comment" ceremony could fit better (demand-driven).
  3. ~~Request-signature authoring~~ ✅ DONE 2026-07-13 (round 6): ordered signer picker (internal + external), tier/order/deadline, envelope request list + cancel in the Activity tab. Remaining slice (demand-driven): per-signer OTP resend from the request card (.../signers/{signerID}/resend), and reordering signers after composing.
  4. ~~PSrE eKYC activation handoff~~ ✅ DONE 2026-07-13 (round 7): activate → poll → unlock, /me.psre_enabled wired to provider status (cached), mock instant-eKYC for e2e. Real-provider verification still awaits the Mekari/Tilaka sandbox channel.
  5. ~~Ask-the-Archive on mobile~~ ✅ COMPLETE 2026-07-13: core (round 5) + conversation list/resume (round 7) + SSE streaming deltas with non-streaming fallback (round 8). Remaining crumb: scope filters (include/exclude folders) — demand-driven.
  6. ~~Semantic search tab upgrade~~ ✅ DONE 2026-07-13 (round 7): "By meaning" section with similarity %, deduped against title hits.
  7. ~~Offline pinning~~ ✅ MVP DONE 2026-07-13 (round 8): preview rendition into app-private OS-encrypted storage, pinned list in Archive, offline open, wipe on sign-out, no offline legal acts. Remaining slices (demand-driven): per-classification pin policy, remote wipe on device revoke.
  8. Share-sheet intake — ½ DONE 2026-07-13 (round 8): "Open with Obscura" (iOS document types, Android VIEW intent) → smart upload preselected. The true share-SHEET extension (iOS share target) remains: needs a separate native target + real devices (1–2 runs).
  9. ~~Batch upload~~ ✅ DONE 2026-07-13 (round 7): multi-pick batch mode (per-file rows + status, shared folder/classification, sequential upload; enrichment via the async pipeline rather than pre-save analysis).
  10. ~~e-Stamp ceremony~~ ✅ DONE 2026-07-22 (sync round): full TERA stamp ceremony — eligibility gate, admin-set stamp appearance inside the placement box, biometric confirm, OTP continuation (202), stamps listed. Live against Peruri Sign-IT on the demo (KEYLA enrollment pending on the provider side).
  11. ~~Screenshot protection policy~~ ✅ DONE 2026-07-13 (round 7): classified (non-none) docs block capture on viewer/sign/meterai screens (expo-screen-capture). Physical-device verification pending (Simulator can't screenshot-test FLAG_SECURE).
  12. ~~Notification preferences~~ ✅ DONE 2026-07-13 (round 7). Checkout/checkin surfacing stays demand-driven.

Platform-parity sync round (2026-07-22)

The platform moved ~224 commits while mobile stood still; this round re-synced the app (commit def66ca, full e2e battery green, incl. a new run-r13-features.cjs probe):

  • Secure Preview adopted everywhere. lib/preview.ts resolves the byte preview vs a secure-preview session (server-rendered per-viewer-marked page images; sid capability URLs, no PDF bytes) and every consumer — viewer + sign/meterai/stamp placement editors (?sign=1 byte-safe images) — falls back to byte PDFs on stacks without the watermark module. The app's own client-side preview watermark is skipped for server-marked images.
  • Sign tiers. global_tier_available=false (Peruri Sign-IT: certified-only) hides the Global card and re-labels the official tier as PSrE; psre_journey replaces the dead quick-activate button with "finish registration + eKYC in the web app" guidance.
  • Routed duties. sign / meterai / stamp workflow tasks render a duty callout + sticky ceremony button — never the approve/reject verbs (both action bars guarded).
  • Task placement seeding. /me/inbox sign tasks now carry the sender's pre-placed box (spec + handlers_inbox.go); the sign ceremony seeds its editor from it once, so a routed signer signs in one tap.
  • Request-signature: mandatory per-signer placement. The server rejects signers[] without placement, so the composer gained a signer-chip row over one placement editor; submit stays disabled until every signer has a box.
  • Parity bits. Share links: password protection (min 6 chars). Account: avatar (bearer-fetched, hash-busted). Document: Description row, obsolete status tone, meterai/stamp inbox icons.
  • Backend fix ridden along: SearchDocumentChunks scan-arity bug (missing editing_locked destination) 500'd every semantic search / ask-archive platform-wide; fixed (be45eae) and deployed to the demo.

Remaining from the sync gap (deliberately deferred): OTP/allowlist share modes on mobile, version-roles awareness (publish state, ?version=N history viewing), doc-type registry picker (needs a public endpoint), office-editing hand-off.

R14 — the five recommendations, shipped (2026-07-22, 0409940)

The user picked all five "worth adding" items; everything landed in one round (new run-r14-features.cjs probe + full battery green):

  1. Publish awareness — the document screen derives published/working/office representations from the version chain (same rule as the web: editableV > 0 && (publishedV === 0 || stale)) and shows a serving-state callout (published vN / stale / not published) with a Publish-to-PDF button exactly when it has work to do. Version rows carry role badges (working copy / published).
  2. Version history viewing — any version row in Activity opens the viewer read-only at ?version=N (secure-preview session or byte fallback, per deployment) under a "read-only view of version N" notice. The protected full mode stays current-version-only.
  3. Share OTP + allowlist — the share sheet's protection chips grew email-OTP and allowlist modes; allowlist takes comma-separated emails/domains (budi@partner.co, @vendor.io) and gates the create button; list rows show their protection.
  4. Records-type picker + description editorGET /document-types (already open to any authed user) feeds chips in upload and in a new Details-tab attribute editor (registry suggests, free text still allowed; PUT /doc-type + PUT /description).
  5. Office hand-off — docx/xlsx/pptx documents show an "open in the web app" banner (copy adapts to whether the OnlyOffice module is licensed) instead of imitating office editing on a phone.

Spec housekeeping: Version.Role, Document.EditingLocked and Me.office_edit were served but undocumented — now in the spec, client regenerated.

Publish → official copy / sent (vocabulary rename, 2026-07-27)

The two entries above are kept as written — they record what shipped on 2026-07-22 — but
the words have since changed, so read them through this table. "Published" was doing three
jobs at once (the render act, the served version, the document's lifecycle state); each now
has one name, with no back-compat aliases (mig 00141_document_sent_official, see
docs/VERSION_ROLES_DEPLOY.md):

was is
POST /documents/{id}/publish POST /documents/{id}/official-copy
POST /documents/{id}/versions/{v}/set-final POST /documents/{id}/versions/{v}/set-official
version role published version role official
document status published document status sent
document status obsolete document status superseded

Behaviour change, not just words: making the official copy no longer moves the
lifecycle status, and completing an approval workflow no longer sets it either. A document
becomes sent only through the new POST /documents/{id}/mark-sent (Editor access) —
409 dms.sent.no_official_copy with no official version, 409 dms.sent.not_draft past
draft, and an idempotent 200 {"already_sent": true} on a repeat.

Mobile followed in lockstep: useMakeOfficialCopy + useMarkDocumentSent in
lib/queries.ts; the Overview callout renamed its derivation (officialV /
officialStale / canMakeOfficialCopy) and now carries two buttons —
official-copy-button (make the PDF) and mark-sent-button (claim it went out, gated on
status==='draft' && officialV > 0, behind a confirm because it starts the retention
clock). Locale keys moved publish.*officialCopy.* + markSent.*,
document.role_publisheddocument.role_official, and status.published /
status.obsoletestatus.sent / status.superseded — the last pair matters because
StatusTag builds its key dynamically from the server enum, so a missed rename degrades
silently to the raw string.

R15/R16 — letter duties, and the sweep that found two server bugs (2026-07-28, 1c08922, bfb66ec, 672e7dc, ae67086)

Letters became actionable. Correspondence runs on the same core workflow engine as
documents (subject_type='letter') and seals are subject-generic, so the phone got the
whole chain rather than approvals only: letters/[id]/index.tsx withholds the approve
verbs when the assigned action is a ceremony and routes to letters/[id]/sign.tsx (tier
cards, swatches, placement editor, biometric, OTP hand-off) or letters/[id]/seal.tsx
(e-Meterai and e-Stamp behind one ?kind= screen). Probe: run-r15-letters.cjs.

Building it turned up a server bug that also affected the web: /me/inbox projected
titles for documents only, so every correspondence task arrived untitled — an inbox row
that says nothing about what it is asking you to sign (1c08922).

The sync sweep found a second one. Rebuilding the e2e harness against HEAD (rather
than reusing yesterday's binary) broke run-r7-features.cjs at the semantic-search
section. It was not a mobile regression: every non-empty semantic query, and every "Ask
the Archive" that retrieves grounding passages, was returning 500
(672e7dc).
SearchDocumentChunks hand-rolled its scan destinations instead of calling the shared
scanDocument, and fell behind documentColumns when that gained sent_version,
sent_at and vars_changed_at — 26 columns selected, 23 destinations offered. pgx
catches that arity mismatch client-side, so postgres logged nothing; the only symptom
was a 500 with no error line anywhere. There is now a guard test that derives both counts
independently and asserts they agree.

Worth keeping as a lesson: the harness binary is part of the test. Reusing a stale one
would have shown ten green suites over a broken feature.

Mobile changes from the same sweep (ae67086):

  • The sent callout stopped guessing. It printed the latest official version — "Sent
    — the client holds PDF copy v3" — while the recipient actually holds whatever was
    dispatched. One re-convert after dispatch and the app asserts a version nobody outside
    the building has seen, on the exact screen people open to answer "what did we send
    them?". Now reads the server's SentVersion/SentAt stamp, with version-less copy for
    documents sent before the stamp existed rather than a fabricated number.
  • Mark-as-sent is visible-but-disabled without a PDF copy, matching web. A hidden
    control reads as "not supported here"; a disabled one with "convert to PDF first"
    teaches the precondition. The server 409s either way.
  • Letters fall back to the byte preview. resolveLetterPreview returned null whenever
    the secure image session was unavailable, on a wrong note that letters had no byte
    channel — GET /letters/{id}/preview exists. With the new raster-outage handling that
    gap bites: the session answers 503 and mobile would blank both the letter screen and the
    sign placement editor while readable bytes sat one call away.

Everything else new on the server was web-only or inherited free (converted-PDF caching,
the sign-ceremony raster fallback, PSRE activation, the profile redesign).

R18 — step-up unlock + tenant awareness (2026-08-04, f966196, b72c511)

The catch-up round after ~285 mainline commits (operator console, billing, Cloud
tenancy, step-up). Two of those changed what a companion app must be able to answer.

Step-up became answerable on mobile. A require_step_up classification refuses
every content channel with 403 auth.step_up_required — fail-closed, no owner or admin
exemption — so a client that does not speak the code renders exactly the most protected
documents as permanently broken. Mobile now recognises the refusal in lib/step-up.ts,
and StepUpGate (a 6-digit TOTP prompt, auth.totp.unconfirmed handled with
enrollment guidance) renders in place of the content on every surface the server gates:
the viewer (both modes — the full/original path is probed first so a 403 no longer
reads as a corrupt PDF), all six placement ceremonies, the AI tab, the export button,
and the Overview hero (a locked strip that says why there is no picture). One grant
covers the 15-minute window, so a folder of gated documents asks once.

Building the probe found a server gap: 33d69dd sealed enrich/enrichment behind
step-up with a comment declaring the summary "carries the same gate" — but
POST /ai/summarize (and /ai/extract) never got the middleware. An on-demand summary
of a sealed document worked without elevation. Gated now (f966196); /ai/classify
deliberately stays open (it answers with a label, not with what the document says).
/auth/step-up was also served but unspecified — added to the OpenAPI contract.

Tenant mode reached the connect flow. On Cloud every API path routes by Host. The
connect probe used to wave an unknown tenant host through (its 404-tolerance for older
servers) and to translate a suspended tenant's 403 into "server unreachable". Both
codes now get their own words at the moment they help: tenant.unresolved fails at
connect, tenant.suspended shows the server's actual reassurance (documents safe,
contact your provider) on connect AND login — where it previously surfaced as
"invalid email or password".

Also from the probe: Card accepted a testID and silently dropped it unless the card
was pressable — the gate was on screen while Playwright counted zero of it.

R19 — the ceremony opens seeded, and two more arity bombs (2026-08-06, 75a7262, 23985a1)

The user's report — "signing from the Inbox shows a blank page" — replayed in a new
end-to-end probe (run-r17-inbox-sign.cjs: inbox → task → duty CTA → ceremony). Three
findings and a critical bycatch:

  1. Ceremonies now seed from the sender's pre-placed box. Document sign/e-Meterai/
    stamp steps are pre-placed at authoring time (server-enforced) and the inbox task
    carries the box; mobile ignored it, asking the signer to re-place by hand with
    submit disabled until they did. All five ceremonies seed placement AND page from
    the task; the duty is one tap on arrival.
  2. The e2e harness finally paints the primary path. The extract stub grew the
    rasterizer endpoints (/pdf/info, /pdf/page, /img/watermark-tile — pure-node PNG
    encoder), so secure-preview sessions mint in the battery and probes assert the
    server-rendered-images mode production uses. Before this, every probe ran the
    degraded byte mode, which react-native-pdf cannot composite on web exports or on
    Fabric — the blank page the user photographed. The demo's own rasterizer is
    healthy, so a REBUILT app gets session images there; the blank page was the old
    build riding the unpaintable fallback.
  3. Module gates went dark against updated servers (nine modules → six; /me only
    reports canonical names). lib/modules.ts canonicalises both sides; the AI tab,
    "by meaning" search, related-suggestions and upload-AI gates all use it.

Bycatch (75a7262): the battery's fixed-email probes started 500ing on login —
userColumnsAliased had fallen one column behind userColumns (sso_claimable),
which 500s every SSO/dev login of an EXISTING identity while first logins pass. The
third arity incident; the aliased list is now derived from the const, with the same
guard-test treatment as dms.

Also: received/final status labels. Next candidate: surfacing /requests/inbox
(document requests assigned to you) with a fulfil flow — a real companion duty, big
enough to deserve its own round.

Assessment — porting the workflows system to mobile (2026-08-07)

Asked: how hard is it? Answer: the port splits cleanly into three tiers, and only the
first is both cheap and right for a phone.
The web feature is ~4,900 LOC of TSX across
15 components; most of that weight is authoring, which mobile deliberately does not do.

What mobile already has. Task inbox (/me/inbox), approve/reject/return, forward
(disposisi), the three seal ceremonies with the sender's pre-placed box, the document's
Workflow tab (instances + history), and definition-driven act-definition. That covers
the common case: you were handed a duty, execute it.

Tier A — duties that are BROKEN or missing today (small, and it is a correctness fix,
not a feature).
The workflow engine has seven step kinds; mobile knows four.
number (assign a letter number) and send (dispatch) route real inbox tasks that
mobile cannot complete:

  • The inbox label ladder ends in t('inbox.action_approve') as its ELSE branch, so an
    assign-number task announces itself as "Approval required" — verified on the
    harness: a number task renders with that tag and a mail icon.
  • The letter screen then offers Approve / Reject, because ceremonyAction only
    matches sign/meterai/stamp. Approving is wrong twice over: for a non-admin the server
    refuses with workflow.act_def.ceremony_task ("completed through its ceremony (sign
    or affix)" — nonsense for a registrar), and for an admin it is ACCEPTED — verified:
    the instance advanced to Approved and the letter was never numbered.
  • The real completions are one call each: POST /workflow/tasks/{taskID}/number
    {scheme_code, letterhead_id} and POST /workflow/tasks/{taskID}/send (no body).
    Mobile already lists numbering schemes in its e2e; letterheads are a plain GET.

Sizing: a scheme-picker screen + a confirm screen + the label/icon ladder + a probe.
~1 round. This is the same dead-end class as the letter step-up fix (R20).

Tier B — starting a run (medium, genuinely useful on a phone). Mobile can execute a
chain but cannot begin one, except through the request-signature preset. Adding "start
this approval chain" over GET /workflow/definitions + POST /workflow/definitions/ {id}/start is a picker plus a confirm — but the start payload carries contacts (per
external slot) and placements (per step, per assignee ref), and the server REFUSES a
document sign chain with no pre-placed box (workflow.custom.placement_required). So
"start" honestly means "start the chains that need no placement", or it drags the
placement editor into an authoring flow. ~1–2 rounds for the restricted form; the
unrestricted form belongs in Tier C. Cancel-a-run-you-started is a bonus one-liner.

Tier C — authoring (large, and against the doctrine). StepListEditor (864 LOC)
alone is per-step kind switching, position AND user multi-selects, completion rules,
tier hints, scheme pinning, contact slots, and a placement box per assignee — plus
TemplatesTab (668), StartStepList (642), CustomWorkflowModal (638), the graph
renderers, archive/revise/share/promote. Rebuilding that on a 390pt screen is weeks, and
it fights the plan's standing rule: mobile executes duties, authoring stays on web.
The permission story agrees — workflow.define sits in the default member role, so the
audience is broad, but the ACT is a desk job.

Recommendation: do Tier A as a correctness round (it closes a verified wrong-action
bug), consider Tier B on demand, and leave Tier C on the web. The honest one-line answer
to "how hard": the part worth porting is a round of work; the part that looks like "the
workflows system" is months and shouldn't be ported.

R23 — the workflows port, all three tiers (2026-08-07, branch feat/mobile-workflows)

The assessment above recommended Tier A only and said Tier C should stay on the web. The
user asked for all three; this is what that produced, and one of its conclusions
corrects the assessment.

Tier A — the registrar duties (a correctness fix). The engine has seven step kinds
and mobile knew four, so number and send fell through to the approve bar while the
inbox label ladder — whose else-branch was "Approval required" — announced an
assign-number duty as an approval. Approving one is refused for a non-admin with a
message about sign/affix ceremonies and ACCEPTED for an admin: reproduced before the
fix, the instance advanced to Approved with the letter unnumbered. Both now have duty
screens (scheme + letterhead for numbering; dispatch gets a screen because it is
terminal), routed by TASK id, and the label is a map with an honest fallback.

Tier B — starting a run. Pick a chain, see what will happen, fill outside-signer
contacts, place each mark, start. The submit stays disabled with a callout naming what
is still missing rather than letting the server refuse afterwards.

Tier C — authoring, and why the assessment was half wrong. The objection to a mobile
builder was really an objection to the web's INSTRUMENT — a wide table of Carbon
multi-selects, 864 lines for the step editor alone. The ACT is not desk work: deciding
"director, then finance, then the registrar" is a judgement made while holding the
thing. So the instrument changed — a chain is a list of cards expanded one at a time,
fields a kind may not legally use are unreachable rather than merely rejected, and the
server's validator (whose reasons are written for people) is shown verbatim. Saving a
template and starting it both work from the phone.

The bug the round flushed out. Every tap-to-place was emitting NaN corners on the web
export: react-native-web's press event carries no locationX/locationY, so the maths
ran on undefined, and JSON.stringify writes NaN as null. The workflow START
endpoint ACCEPTED that — a task "pre-placed" at {page, llx: null, …}, a signature box
with no position handed to a signer as though it had one. Tier C's stricter step
validator refused it and exposed the path. The editor now measures its own window origin,
falls back to pageX/pageY, and refuses to emit a placement it cannot compute.

Open for Main: /users/pickable caps at 200 with no search parameter, so past that a
mobile picker silently cannot see part of the organisation. Raised the client limit and
added a search field; a q parameter is the real fix. And the start endpoint accepting a
null-coordinate placement is worth a look even though mobile no longer sends one.

Probes: run-r20-registrar-duties.cjs, run-r21-start-workflow.cjs,
run-r22-build-workflow.cjs — 17-suite battery green.

10. Success criteria for the "great UX" mandate

  • Push → signed/approved in ≤ 60 seconds and ≤ 5 taps (measured, not vibes — instrument the funnel).
  • Placement editor: first-time user places a signature correctly without instruction (hallway-test 5 people).
  • Zero "what happened?" states: every async job visible in Inbox until terminal; kill the app mid-OTP and resume losslessly.
  • A rejected workflow always carries a human-readable reason.
  • Crash-free sessions > 99.7%; cold start < 2 s on a mid-range Android (the Indonesian enterprise fleet is not all iPhones).

11. Open questions [?]

  1. e-Stamp: visual org stamp (a) vs provider e-Seal (b) vs both — which does the roadmap mean? (§5.7)
  2. Push for on-prem/air-gapped deployments — self-hosted relay tier or "mobile requires egress"?
  3. Nutrient/PSPDFKit budget (license is per-app, non-trivial) — decide after prototyping placement on react-native-pdf.
  4. Batch approval — hold the line unless a pilot customer demands it?
  5. Does mobile need the trash/retention surfaces at all, or is read-only visibility enough?
  6. SaaS fork alignment: when obscura-cloud starts, the app gains a tenant/server picker on login — design the auth screen with that slot now (cheap), build it later.

Appendix — iOS status (why the build is Android-only so far)

The app is already iOS-code-compatible: Expo/React Native, the iOS bundleIdentifier, and every native module used (SecureStore/Keychain, expo-local-authentication for Face ID/Touch ID, expo-notifications for APNs, react-native-pdf) are cross-platform, and the Phase 0 backend already ships an APNs sender. Nothing in the code is Android-specific except the usesCleartextTraffic dev shim (iOS is HTTPS-only anyway).

What's missing is building and testing iOS, a hard infrastructure constraint rather than a code one: an iOS build requires macOS + Xcode (or EAS cloud builds) plus an Apple Developer account for signing, and running it requires the iOS Simulator (macOS-only) or a physical device. The dev box is Linux with no Apple toolchain, so an iOS build here would be unverifiable — exactly what this project has avoided by testing every change on the redroid Android rig. iOS is therefore gated on a Mac/EAS + Apple Developer account — a decision for when Apple credentials exist, not an engineering task blocked in the code. This is why Phase 2 (fully buildable and device-verifiable on Android now) was taken first.