Note processing and synchronization architecture

SpareThought uses native handwriting rendering and database-only synchronization for journals, meeting notebooks and ToDo handwriting. This document explains how notes are opened, displayed, edited, saved locally and synchronized across devices.

Known recovery and acquisition defects are listed under Known implementation gaps and evidence boundaries. Describing an intended invariant does not mean every edge case already satisfies it. Implementation and device-verification evidence is recorded in the validation reports.

1. System boundaries and ownership

The app owns the editable working copy and local durability. g-paper owns native pen input, brush geometry and drawing. The server owns canonical account content, conflict retention and replay fences. PowerSync delivers server changes to each account’s local replica; the app’s upload owners send bounded mutations through the authenticated API.

Components and note data flow
Owner Main implementation Responsibility

Account lifetime

AccountCoordinator, AccountScope, NotebookWorkspace

Account-separated storage, generation checks, process-owned saves and sync jobs; settle admitted work before switching accounts.

Editor

MainActivity, NotebookSession, JournalHistory, TaskPaperSession

Native view integration, page-coordinate strokes, edit grouping, selection and bounded undo/redo. ToDo drafts have an explicit Save/Cancel boundary.

Local durability

NotebookSaveCoordinator, SqliteNotebookRepository, NotebookDatabase

Freeze save generations, prepare changed content outside transactions, commit working state and durable intents atomically, acknowledge only completed writes.

Binary content

PageStorage, InkStorage, InkChunkCodec, InkChunkPacker

Metadata headers, exact samples, immutable frames, stroke revisions and ordering.

Upload scheduling

SyncUploadAuthorization, SyncOutbox, InkOutbox, AccountSync

Capture trigger-authorized snapshots, acquire bounded work, preserve retries, apply acknowledgments and wait for canonical confirmation.

Download and application

InkDownload, InkProjection, SyncProjection

Verify bounded incoming references and frames, then project complete content without replacing protected local edits or active drafts.

Server

sync/ink-upload.ts, sync/replay.ts, repository and transaction owners

Validate authorization and dependencies, commit each operation with its outcome, retain conflicts, retire acknowledged results and reclaim unowned content.

There are two local representations to distinguish: PowerSync’s downloaded server rows and the app’s working tables. Incoming server data does not directly become visible ink. Verification and projection mediate that transition. The local working copy can legitimately be ahead of, or in conflict with, the server.

1.1. Account and execution boundaries

Each activation captures an account/generation stamp. The account coordinator holds its short publication lock to admit work and increment an in-flight count, then releases it before database work. A switch closes admission and waits for already-admitted transactions to drain before rebinding the workspace. This keeps ordinary editing checks from waiting behind database or JSON work while preventing an old account’s result from publishing into a new workspace.

Save jobs belong to the workspace’s process scope, not a disposable Activity. Caller cancellation does not cancel an already-owned durable save acknowledgment. This ownership ends with the process: process death can still lose edits newer than the last completed transaction. Restart reconstructs from storage, not from a promise that an in-memory coroutine would eventually finish.

The supported installation starts with the current schema: page and ToDo metadata use wire version 2, with editable samples stored in binary ink rows. There is no inline-JSON reader, automatic conversion, or mixed-client compatibility path. Notebook and settings wire version 1 and binary codec version 1 remain current formats; their version numbers do not imply legacy support. See ADR-005.

2. What a note contains

A notebook carries context, page identities, paper settings and metadata. A page has independently addressed strokes. A ToDo may also own an ink container, using the same stroke protocol. Viewport, zoom and device calendar identifiers remain local; they are not additional replicated note content.

Metadata
Record Meaning

Metadata

Notebook/page/ToDo identity, relationship, settings and tombstone state. Wire version-2 page/ToDo metadata has no stroke arrays. Local page JSON uses an inkFormat marker and count; these version numbers name different contracts.

Stroke head

Stable stroke/container identity, current revision or deletion, immutable predecessor and order clock, plus the server’s compare-and-swap revision.

Revision

An immutable stroke candidate: style, color, exact width bits, total sample count, part count and reference-chain digest.

Reference

One ordinal’s chunk hash, part, offset and count. References are rows; there is no growing page-wide list of all sample bytes in one upload record.

Chunk/frame

Immutable encoded bytes, addressed by SHA-256 of those exact bytes. Short strokes can share a frame; a long stroke can span many frames.

Retained owner

Current heads, pending transfers, trigger authorizations, conflicts and supported copy/recovery state keep their required revisions alive. Removing one owner does not authorize deleting bytes still needed by another.

2.1. Exact binary representation

The chosen frame is SPIN version 1 with a 20-byte header and a decoded body of at most 65,536 bytes. Its maximum stored frame size is 65,556 bytes. It stores little-endian sample columns: x, y, pressure, tilt and azimuth as exact float32 bits, followed by signed int64 timestamps. Identity, brush, color and width remain editable data. No point reduction, rounding or bitmap substitution is enabled.

Deflate level 1 compresses the body when it makes it smaller; otherwise the body is raw. CRC32 checks decoded corruption; SHA-256 binds the immutable stored frame. Different runtimes need not produce identical compressed bytes for equal samples, so a retry uses the bytes already persisted rather than recompressing its candidate.

Server payloads live in PostgreSQL bytea; local working payloads use SQLite BLOBs. Base64 is used at the JSON/PowerSync text boundary, expanding a maximum frame to 87,408 characters before surrounding JSON. Note content requires no S3-style object store. The development stack’s other storage services do not change this note-content boundary.

The codec is bounded independently of an entire stroke. A decoder does not allocate the declared total sample count just because a frame claims it. Reference-chain verification checks contiguous offsets, matching metadata and final digest before a revision becomes complete. Missing parts remain incomplete, never a valid shorter stroke or an empty page.

2.2. Drawing order and edit identity

Each stroke has a predecessor, a logical clock and an installation identity. Children of a predecessor sort deterministically by descending clock, installation ID and stroke ID, then project through an iterative traversal. This preserves concurrent additions without rewriting a page-wide order array. Deleted nodes remain anchors for surviving descendants. Transforming a stroke changes its sample revision, not its order identity.

The conflict unit is a stroke, not a compressed frame. Two devices can add distinct strokes even if their samples eventually share storage. Incompatible changes to the same head use its expected server revision and retain a losing candidate for review instead of silently choosing by wall-clock timestamp.

3. Opening and displaying a note

  1. A journal date or meeting entry resolves the notebook identity in the current account. Merely viewing an untouched blank date does not need to create a saved note.

  2. The ordinary editor open path reads notebook and page headers. A nonempty unloaded page carries PageInk.Stored(count): that is a residency handle, not an empty page.

  3. The selected page is loaded with a notebook-revision check. InkStorage reads bounded frames, verifies their references and assembles exact strokes in logical order.

  4. NotebookSession adopts the loaded page; other page payloads can stay in storage. Saved inactive samples may be released. Dirty or failed content remains owned.

  5. renderPage() prepares bounds and background, converts page coordinates to view coordinates, swaps the native content and applies tools and exclusion regions.

When the user opens yesterday’s journal, the app loads that journal’s headers and the chosen page, not every previously visited notebook’s samples. If a remote change invalidates the revision while opening, the load reports that change rather than attaching samples to the wrong snapshot. A failed page load uses the existing error path; it does not overwrite the note with an empty list.

Full page installation is appropriate when opening, swapping content or settling some explicit view changes. It is not the normal response to every pen-up. The committed-stroke callback receives ink that the native engine has already painted. Replaying the entire page there would reintroduce the rendering problem.

3.1. Native ink and navigation

SpareThought consumes pinned g-paper core/BOOX AARs and source JARs from the checked-in Maven repository. Binary artifacts are in Git LFS, with source revision and hashes recorded in upstream.json. Library changes belong in g-paper and are adopted under a new immutable version; the app has no second editable vendored fork.

The renderer appends ordinary committed ink incrementally, caches geometry and brush work, and repairs affected regions for edits. Replaying intersecting content in drawing order preserves transparency. These render caches are separate from authoritative editable strokes and may be discarded without deleting notes.

When the user pinches or drags the page, the app previews the native drawing under the temporary viewport transformation, then settles page/view coordinates when the gesture ends or is cancelled. Ink is stored in page coordinates, so zooming does not rewrite saved stroke samples. The native surface must return to settled coordinates before a new pen contact. g-paper owns eight resize handles plus the rotation handle; the app records each completed transform as an edit.

4. Writing, erasing and undoing

Local stroke and durable save sequence

When the user writes a word, g-paper receives pen samples and commits native ink. The app converts the committed stroke from view coordinates to page coordinates, applies page-bound rules, and passes the edit into the notebook session. Ordinary stroke collection updates still have some collection bookkeeping/copying; the implementation does not promise constant-cost editing for arbitrary page sizes.

The session records an edit and marks a new local generation dirty. Undo uses compact edit records where implemented, with exact inverse content. Eraser work is grouped by contact; one completed transform makes one undo step. A remote content generation invalidates stale history rather than letting an older undo snapshot overwrite incoming ink.

When the user erases a line and chooses Undo, the visible line is restored locally as another edit. Subsequent save/sync changes the corresponding current heads; it does not rewind server revision counters. Deleted order anchors remain useful for neighboring strokes. Undo history is finite and is not a permanent backup.

5. Automatic durable local saving

Two timers serve different writing patterns:

  • A 500 ms quiet period coalesces short bursts.

  • The first unsaved edit anchors a 2 s save-attempt deadline, so continuous pen-ups cannot postpone saving forever.

The 2 s value is a scheduling target, not a guaranteed transaction completion time. The acceptance target for completed saves is a dirty age within 5 s under healthy local I/O. OS suspension or failing storage can exceed it; those failures must not be reported as saved ink.

The coordinator serializes saves per notebook. It captures a generation, compares against the acknowledged snapshot and prepares changed pages off the interaction thread. InkStorage.prepare() packs changed strokes. Unchanged strokes retain their revisions and frames; it does not repack all previous ink just to append a stroke. The current producer repacks an entire changed stroke, so changing one sample of a very long stroke can still encode multiple frames. Bounded records do not imply a single-row write for every possible edit.

The local transaction stores page metadata, changed heads/revisions/references, frame BLOBs and durable upload intents together. Only after commit does the coordinator acknowledge that generation. If writing continued during the save, the newer generation stays dirty and receives another save. A successful local save does not itself authorize note upload.

When the user writes continuously with Wi-Fi off, periodic local saves still run. After an acknowledged save, restarting reloads its exact committed ink. An abrupt termination before the next commit can lose the newer unsaved interval; neither the on-screen stroke nor the live-ink plane is proof of disk durability.

5.1. Save failures and memory ownership

Failed saves keep accepted ink and a latched error. New handwriting pauses after the active contact finishes, and Retry attempts durable saving again. Recovery controls such as Undo and Delete remain available; failed ink is not evicted to make a budget look satisfied.

The defaults include an 8 MiB estimated undo budget and 200 commands across the session, a 50-command page ceiling, a 16 MiB/256-entry inactive save cache, and an 88 MiB estimated combined editor budget. The 80 MiB retained target helps trim optional roots before the combined gate. Navigation histories cap at 128 journal entries and 128 meeting return frames. Native coordinate copies and protected operations participate in memory accounting.

These are estimated ownership budgets, not a hard bound on Android PSS, GPU/native allocations or total heap. If evicting persisted reloadable roots is insufficient, new handwriting pauses at a safe contact boundary. Current, unsaved, failed and in-flight content stays owned. A single oversized undo operation can clear that notebook’s history while retaining the accepted edit.

6. Authorizing uploads without coupling them to saving

Trigger authorization with a newer edit during upload

Only these note actions schedule account-wide upload preparation:

  • The user requests Sync.

  • The user leaves a page, including an exit to the overview.

  • The user switches notebooks.

Opening an ordinary modal, a pen-up, an autosave, a debounce expiry or reconnection is not a new note-upload trigger. Navigation can await its local save to protect accepted ink, but never awaits network completion. NotebookWorkspace schedules background preparation in its process scope.

The save coordinator captures pending notebook roots at their selected generations. Already durable work is authorized in SQLite; each pending triggered save commits its own authorization atomically with its saved content. sync_authorized_notes stores metadata drafts/generations, while ink_authorizations stores the immutable revision and order selected for each stroke. An eligibility flag on the mutable current head would be insufficient: a later local edit could otherwise replace the authorized candidate before transport reads it.

When the user saves stroke A, taps Sync, then writes stroke B while the network is busy, A remains the selected candidate and B stays locally durable but untriggered. If the app restarts or reconnects, already-authorized work resumes. B waits for another trigger. Tapping Sync again while A is in flight authorizes newer work without changing A’s immutable retry request.

ToDo handwriting follows this policy after the user presses Save in its editor. An open ToDo draft is not covered by the notebook autosave label. Cancel discards the draft through the existing editor behavior. Standalone text-only ToDo changes and settings still synchronize automatically where parent/content dependencies allow; their behavior was not converted into manual-only synchronization.

7. Upload processing and server transactions

Normal ink transfer and retained terminal-error states

The diagram shows a revision upload. A deletion instead ensures an order anchor and activates a null revision; it has no sample frames to send. Resolution, abandonment and replay-recovery queues have their own bounded control operations.

  1. InkOutbox promotes authorized head snapshots into durable transfers. A transfer pins candidate revision, generation, order and expected remote head.

  2. The client persists each exact mutation envelope and replay binding before sending it. A transport timeout leaves the identity and bytes intact.

  3. DECLARE establishes a candidate; FRAME sends bounded bytes; REFERENCE validates the next segment; ACTIVATE attempts to make a complete revision current.

  4. Missing dependencies return DEFERRED, retaining the same operation for retry. A missing-frame repair can resend exact locally retained frame bytes.

  5. A successful result advances only the matching in-flight stage/generation. Activation acknowledgment moves to confirmation; it does not mean every device already displays the content.

Acquisition admits at most 32 operations and 512 KiB of prepared work, reserving up to 96 KiB for a newly prepared operation before reading its payload. The HTTP body cap remains 4 MiB; client batches deliberately fit below it, including replay framing. Byte and count admission happen before loading an entire backlog. Newer unsent drafts coalesce; the queue is not simply one immutable page per stroke.

The server authenticates the HTTP request and processes bounded operations in order. Each operation has its own account/session-checked transaction containing replay admission, dependency/head validation, content mutation and terminal outcome. The whole HTTP batch is not one atomic page edit. A connection can fail after some operations commit; exact retry identities recover their outcomes. A partially received stroke revision cannot become visible as complete ink, but separate strokes from one editor gesture can become visible across multiple operations.

Batching reduces HTTP/authentication envelope overhead. It does not automatically reduce SQL commands or the number of per-operation transactions. The measured batch work and retained limits are documented in the trigger-policy report and the capacity report.

8. Receiving, verifying and displaying remote ink

PowerSync replicates the account-scoped publication: SyncRecord, SyncReceipt, SyncConflict, InkChunk, InkStroke, InkRevision, InkReference and InkConflict. Authentication/billing records and the private replay ledger are not part of that content stream.

InkDownload works through live heads and retained conflict candidates, checking bounded reference batches and recording verified prefixes. Out-of-order delivery is expected: a head may arrive before its frames. This leaves the new revision pending while prior usable content remains; it is not permission to display an empty replacement. Unrelated ready containers can proceed.

Once dependencies are verified, the remote-application owner coordinates the editing gate and local transaction. InkProjection treats local intents, transfers and resolutions as protected owners. Incoming server rows cannot simply overwrite a dirty local head. The active editor is refreshed only at the allowed editing boundary; input-pause ownership is released even after a failed reload. Downloaded rows and visible UI can therefore temporarily differ, especially while a ToDo draft remains open.

When the user draws on BOOX and leaves the page, background upload starts. OP6 receives the server records, verifies the revision, projects it into the working copy and reloads the relevant view. If OP6 is actively editing a protected draft, receipt of the bytes does not force replacement of that draft. Closing/settling the editor permits application through the existing gate.

9. Conflicts, retry identity and reclamation

A timeout is ambiguous: the server might have committed before the response was lost. Retrying an identical mutation returns its existing result. Reusing its ID for different content is invalid. A later user decision is a new operation with its own identity, not a modified retry.

Successful receipts retain compact outcomes instead of historical page payloads. The server’s private installation ledger binds epoch, sequence, mutation ID and request digest. Each installation has at most 256 admitted slots; retirement removes completed outcomes without moving the permanent sequence ceiling backward. Old requests cannot be replayed as new just because their receipt was removed. Epoch expiry/re-entry requires reconciliation of retained outcomes before reissuing proven-uncommitted work. A 90-day epoch is a protocol fence, not a blanket timer for deleting notes or conflict candidates.

When two devices transform the same stroke while offline, both local candidates remain durable. The first valid activation changes the head; the incompatible activation retains a conflict. Review compares against current identities and generations. An explicit local/remote/restore/copy choice retains the required candidate until its resolution is durable and confirmed. A stale dialog must not silently overwrite intervening ink.

Reclamation follows ownership, not just age. Current heads, unresolved conflicts, pending uploads and supported recovery/copy state pin content. ABANDON identifies the exact declaration owner being released; a delayed release cannot destroy a newer declaration. Frame sharing means one unused segment does not make the whole frame reclaimable. Compact ordering/replay metadata can remain even after payload collection; zero database-file growth is not an invariant.

10. User-visible state examples

When the user does X Y is triggered What the state means

Writes without navigating

Session edit, automatic local save, durable intent; no upload authorization

Saving, then saved locally and awaiting a sync trigger

Taps Sync while offline

Save/authorization is persisted; transport cannot finish yet

Authorized pending work survives restart and can resume on reconnect

Reconnects after writing without a trigger

Receiving and previously authorized work can resume

New untriggered note edits remain local

Changes page or notebook

Local save protection, destination loading and background account-wide sync scheduling

Navigation does not wait for network completion

Writes while an earlier upload is active

A new local generation is saved separately

Its authorization depends on a later trigger

Receives an edit while an active ToDo draft is open

Replica verification proceeds; visible application is deferred by the draft gate

Downloaded does not necessarily mean already visible in that draft

Restarts after a completed local save

Working metadata and binary revisions reload from account storage

The acknowledged saved boundary survives; later unsaved edits are not guaranteed

Hits a local save error

Failure remains latched and new handwriting pauses after contact

Accepted ink stays in memory; Retry/Undo/Delete are recovery controls

The Sync dialog distinguishes local durability, saved work awaiting a trigger, queued/uploading work, uploaded work awaiting confirmation, and actionable failures. A network connection alone is not “Up to date”. Rejected candidates are not counted as ordinary queued uploads. Estimates can grow as bounded envelopes are prepared.

11. Known implementation gaps and evidence boundaries

The following implementation issues remain unresolved:

  1. Rejected non-conflict transfers: InkOutbox retains READ_ONLY and other terminal rejections in ERROR, while InkResolutions lists conflict-backed rows. Renewing access and pressing Sync is not currently a general recovery path for that candidate or subsequent edits to the same head. Recovery needs explicit candidate preservation and a fresh operation identity. Retention is not the same as user-accessible recovery.

  2. Meeting metadata operations: relink(), refreshSnapshot() and delete() still use an ink-loading helper by default. A meeting whose individually valid pages exceed the aggregate 64 MiB full-load limit can fail these metadata operations. The intended header-only residency boundary is not consistently applied there.

  3. ToDo overview limits: the overview reader rejects more than 4,096 tasks or 8 MiB of aggregate metadata, while writes/remote projection can still admit that saved collection. This can make the entire overview unavailable after restart. The current read rejection is not a recoverable pagination policy.

Source inspection, host tests and physical-device observations provide different kinds of evidence. A successful build or automated navigation test does not establish physical BOOX stylus/live-ink correctness. Recovery, responsiveness and capacity claims require measurements appropriate to the behavior being assessed. The linked validation reports record measured results and their limitations.

12. Architecture decision records

See the ADR section for the decisions and their consequences.

13. Source and contract map

Concern Entry points

Account and jobs

AccountScope.kt, NotebookWorkspace.kt

Editing and display

MainActivity.kt, NotebookSession.kt, g-paper dependency

Saving and local ink

NotebookSaveCoordinator.kt, SqliteNotebookRepository.kt, InkStorage.kt

Trigger and upload

SyncUploadAuthorization.kt, InkOutbox.kt, AccountSync.kt, ink-upload.ts

Reception and resolution

InkDownload.kt, InkProjection.kt, InkResolutions.kt

Wire contracts

Frame v1, Revisions and order, Upload protocol

Measured behavior

Save/sync scenarios, Memory budgets, Storage accounting, Acceptance matrix

Protocol documents define binary fields and wire behavior. Use implementation source and validation reports to assess integration status; a protocol definition alone does not establish that every required behavior has been verified.

14. Maintaining this documentation