Specifications / Application frameworks

Reactive Mobile Applications

Status: the application uses callable value-effect handlers at its Osprey/native boundary. The current migration passes shared domain tests, iOS/Android LLVM and ABI generation, and native C-entrypoint checks on Linux. Earlier simulator, device, and hosted CI results predate this migration; see the current validation record.

The Issue Inbox sample shares its application state, repository validation, GitHub decoding, SQL statements, filtering, bookmarking, issue details, local notes/priorities, messages, and UI tree in Osprey. Swift and Kotlin provide native rendering and platform services. Both applications compile the same examples/mobile/inbox/ project through the C ABI.

The application uses callable value-effect handlers and event messages. Storage::Requests declares typed SQL and HTTP requests; Storage::commands interprets them as the command JSON consumed by each host. Test handlers can capture the same requests. Platform completion remains asynchronous event processing; mobile dynamic control operations are unsupported.

Native application boundary [MOBILE-NATIVE-HOST]

The host initializes the library through osprey_main(), then calls these generated exports on its UI thread:

const char *osprey_mobile_start(void);
const char *osprey_mobile_dispatch(const char *model, const char *event);

Calls are synchronous. The host serializes calls, keeps input strings alive until return, and immediately copies the returned UTF-8 string. The host owns the resulting copy. iOS uses the generated C header through a Swift bridging header; Android uses a JNI adapter over the same C declarations. Memory and initialization follow the iOS target and Android target specifications.

Every result is a JSON envelope containing model, view, ui, and commands. model is an opaque JSON string that the host returns unchanged with the next event. view is structured diagnostic data. ui is the native rendering description. commands is an ordered array of requested platform operations. Invalid JSON, missing fields, unsupported UI nodes, and invalid commands must be exposed as host errors rather than displayed as successful application updates.

Reactive event processing [MOBILE-REACTIVE-HOST]

mobile::start produces initial state and the first commands. mobile::dispatch decodes the previous state and one event, applies Update, derives View, renders Ui, and returns the next envelope. The host stores the opaque model and publishes the new UI immediately, then executes emitted commands in order. Each completion is another event passed through the same dispatcher. Network work runs asynchronously in the host; there is no suspended Osprey call waiting for it.

UI events include refresh, repository submission, search changes, filter changes, bookmark toggles, issue opening/back navigation, note submission, and priority changes. The renderer forwards the event object supplied by Osprey; text inputs add the current value. It does not decide which issues match a query, whether a repository is valid, or what a bookmark or annotation means.

The Osprey model carries pending HTTP and SQL write identifiers. Only a completion matching the current request is applied. Older responses must not replace a newer repository selection or clear the status of a later save. UI events may arrive while HTTP is pending; each still invokes Osprey synchronously on the same thread.

Startup creates the schema and reads the stored snapshot. A valid snapshot opens offline, with refresh left to the user. An empty cache requests the default public repository, swiftlang/swift. Successful refreshes, bookmark changes, notes, and priorities persist a versioned snapshot. Search, the selected All/Saved filter, and the opened issue are transient. HTTP failures retain cached issue data. Database and malformed-cache errors remain visible. Notes and normal/high priorities belong to this local inbox and are never posted to GitHub.

UI rendering [MOBILE-REACTIVE-UI]

The shared Ui module specifies labels, layout, input values, issue cards, empty states, status messages, actions, and links. Both native renderers understand column, row, scroll, text, rich, button, input, link, and divider nodes. Optional fields include id, text, style, value, placeholder, url, submit, event, children, and spans.

Native code maps these generic nodes and style names to SwiftUI or Android widgets. Platform typography, colors, keyboard handling, accessibility identifiers, and opening HTTPS links remain native responsibilities. Application-specific layout and behavior stay in Osprey. The platforms need not produce identical pixels, but must display the same state and send equivalent events for the same interactions.

The structured view contains repository, search, filter, status, error, loading state, visible issues, issue count, bookmark count, selected issue ID, and a nullable detail object. Each issue provides its stable string ID, issue number, title, URL, author, comment count, bookmark state, description, labels, note, and priority. Descriptions and notes are bounded to 2,000 Unicode scalar values, and search to 256; truncation must preserve complete UTF-8 sequences. Annotations are bounded to 200 issue IDs. This view supports diagnostics and assertions; the native renderer consumes ui for the actual screen.

The detail screen is also assembled in Ui: back navigation, issue metadata, labels and description, normal/high priority buttons, a note input submitted with Done, a bookmark action, and a GitHub link. Native code receives the same generic node types as the list screen, plus the rich node the description uses. Multiline note editing is not implemented.

Issue description Markdown [MOBILE-MARKDOWN]

GitHub issue bodies are Markdown, and the shared Markdown module (inbox/src/markdown.ospml) turns a description into ordinary UI nodes before it reaches a host. Osprey decides the structure; the hosts only draw it. The view keeps the raw Markdown source so assertions and diagnostics can compare it with what the API returned.

Block syntax, one construct per line, recognised after leading whitespace is removed:

Markdown Node
# to ###### followed by a space rich with style heading (levels 1 and 2) or subheading (3 to 6)
``` fence until the closing fence text with style code; lines and blank lines inside are kept verbatim
- , * , + row with style item: a text bullet • and a rich body
1. with any number the same row, bullet 1.
- [ ] and - [x] the same row, bullet ☐ or ☑
> column with style quote around a rich body
three or more -, * or _ alone divider with style rule
a blank line ends the current paragraph
anything else joins the current paragraph; a single newline stays a line break, as GitHub renders it

An unclosed fence still renders as code, #tag without a space is text, and seven or more # are text.

A rich node carries spans, an ordered array of {"text", "style", "url"} objects. Inline syntax inside paragraphs, headings, list items and quotes: **bold**, *italic*, `code` and [text](https://…). A span style is plain, bold, italic, code or link; only a link span has a non-empty url, and only https:// URLs become links. A marker without a closing marker, an empty ****, and a non-HTTPS link are literal text, so every description renders. An underscore pair is emphasis only at a word boundary, so GitHub's _No Description_ placeholder is italic while snake_case identifiers stay literal. Nested emphasis is not interpreted; the inner text is shown as written.

Native renderers concatenate the spans into one text view: SwiftUI through AttributedString presentation intents and link attributes, Android through StyleSpan, TypefaceSpan and URLSpan. The host smoke fixtures include a bold marker in an issue body and assert that the rendered tree contains a bold span while the view still holds the raw Markdown. Tables, images, HTML, footnotes, task-list toggling and syntax highlighting are not implemented; they render as text.

Effect boundary [MOBILE-EFFECT-BOUNDARY]

Update requests Storage::Requests.sql or Storage::Requests.http with typed request records. Those value operations return serialized command descriptions. App::initial and App::dispatch run the update functions through the callable Storage::commands handler, so each scalar C export discharges its requirements before returning to the host.

The handler builds descriptions; it does not wait for SQLite or HTTP completion. The existing host protocol below carries those requests and their later results. Tests may substitute a handler that records the request ID, SQL parameters, or URL without executing platform work. Pure view rendering and cache decoding do not require a service handler.

Platform commands [MOBILE-HOST-SERVICES]

Command Fields Completion event
SQLite kind: "sql", id, sql, params type: "sql", matching id, ok, rows, error
HTTPS GET kind: "http", id, url type: "http", matching id, status, body, error

Osprey supplies the actual SQL and parameters. The host prepares one statement, verifies and binds its scalar parameters, executes it, and returns rows as JSON objects keyed by column name. Parameters support null, booleans, 64-bit integers, finite floating-point numbers, and UTF-8 strings. BLOB values and multiple statements are outside this transport contract. Values must never be interpolated into SQL text. The SQLite binding contract requires keeping bound bytes valid or asking SQLite to copy them; the iOS implementation uses SQLITE_TRANSIENT. See SQLite parameter binding.

The initial schema is app_state(key TEXT PRIMARY KEY, value TEXT NOT NULL). The inbox row holds Osprey's versioned snapshot. This is a durable application cache, not an in-memory substitute for SQL. Invalid snapshots are reported and replaced only by a subsequent successful refresh/save.

The HTTP host performs bounded HTTPS GET requests with a User-Agent and JSON Accept header, returning the HTTP status and raw UTF-8 body. It does not decode issues or decide whether an HTTP status is successful. Transport failures populate error. Requests have finite timeouts and a 2 MiB response limit. Authentication, pagination, retries, and background refresh are not implemented by this sample.

Github requests one page of open repository issues and validates the response in Osprey. It excludes entries with pull_request, because GitHub's issue endpoint also returns pull requests. IDs and issue numbers must be positive; required text fields and comment counts are checked. Issue links are built from the validated repository and issue number.

Host assertions [MOBILE-HOST-VERIFICATION]

Native smoke tests run the real application and C ABI against a separate SQLite database. They must not erase or replace the normal application cache. Both platforms provide deterministic HTTP completions containing issues and a pull request. Android also provides a separate live-network smoke mode and verifies a process restart against its saved cache.

The iOS smoke checks scalar SQL binding, Unicode and NUL text, parameter injection resistance, statement-count rejection, database reopen durability, initial Osprey rendering, pull-request exclusion, search, bookmarks, the saved filter, issue open/back events, note/priority persistence, cache reload without HTTP, and visible HTTP failure while retaining cached state. It writes OSPREY_INBOX_SMOKE_OK to Documents/inbox-smoke-result.txt only after all assertions pass. The launcher removes the previous marker and requires fresh output.

The Android smoke exercises shared state transitions through JNI, Android SQLite, and native rendering, including Unicode search, bookmarks, saved filtering, and cache restoration after terminating the process. Its launch script owns isolated test storage and validates fresh JSON results for the initial and restored phases. --smoke uses fixtures, while --live-smoke fetches actual public GitHub issues. A network outage or rate limit can fail the live mode without failing the offline fixture test. A build or successful process launch alone is not a passing application smoke test.

Verification and diagnostics [MOBILE-VERIFICATION]

examples/mobile/README.md contains the reproducible build and launch commands. Final acceptance requires shared Osprey checks, both native application builds, deterministic host smoke on both platforms, a live public GitHub response displayed by the application, and a subsequent cache-backed launch.

Hosted CI is configured to run the deterministic smoke of the application on an iPhone simulator and an Android x86-64 emulator for every pull request, after the whole tests/ corpus has crossed the same C ABI (iOS, Android). Manual runs also exercised a signed build on a physical iPhone 16 and the Android ARM64 slice, including SQLite restoration and live GitHub responses. The signed-iPhone run predates Markdown descriptions and the callable handler migration; a later pre-migration build was installed on that phone but its smoke did not execute there, as the iOS target records. Simulator, emulator, device, and hosted CI results have not been refreshed for the callable handler migration. Re-run the platform commands in the application README to refresh that evidence.

On iOS, --inbox-diagnostics writes the latest complete envelope to Documents/inbox-state.json, and --inbox-open-first sends one ordinary open event for the first loaded issue once startup commands finish, so the Markdown detail screen can be captured without simulated taps. Both are explicit local debugging modes, disabled during ordinary launch. The envelope allows checking that the displayed UI, issue data, pending commands, and persisted application state came from Osprey. Screenshots provide visual evidence alongside those protocol assertions.

Source responsibilities are inbox/src/{model,update,annotations,github,storage,view,ui,app}.ospml, the scalar entry wrappers in inbox/src/main.osp, the Swift files in ios/IssueInbox/, and the Kotlin/JNI files in android/app/src/main/. Dedicated reactive dependency inference, platform GPU rendering, private GitHub authentication, full repository pagination, cache migration across future schema versions, and an Osprey string-release API are outside this delivery. The default runtime's general allocations remain alive for the process lifetime, so this is not yet a bounded-memory deployment contract.