Architecture decision records
These architecture decision records explain the context, adopted choices, alternatives and consequences of SpareThought’s design. An accepted decision does not imply that every implementation path is complete; see the known limitations.
ADR-001: Keep native rendering in g-paper and consume immutable binaries
Status: Accepted and implemented.
Context: Full committed-layer reconstruction after each stroke made writing progressively more expensive. The app also had a vendored library copy, so fixing the standalone repository alone did not update the consumed implementation.
Decision: Put incremental committed rendering, geometry/brush caching, affected-region repair and native transform behavior in g-paper. SpareThought adapts to its standard eight-handle selection controls. Publish core and BOOX artifacts under immutable, traceable versions, and keep the Maven artifacts and provenance in SpareThought with Git LFS for binary files.
Alternatives: Continue maintaining a second source fork, rebuild the sibling checkout implicitly in every app build, or duplicate rendering policy in the app. These would obscure the consumed revision or divide ownership of the same behavior.
Consequences: App builds do not need an adjacent g-paper checkout. Library fixes need a deliberate build/adoption step with matching metadata, licenses and hashes. Render caches remain disposable; editable stroke data remains authoritative. Physical live-ink correctness still requires physical evidence, independent of a successful library build. Current dependency adoption is documented in the g-paper package README.
ADR-002: Separate account admission from long-running work
Status: Accepted and implemented.
Context: UI editing checks and background synchronization shared an account lock. Holding it while doing database/JSON work could block pen-input handling. Removing the lock without replacement would allow account-switch races.
Decision: Admit generation-bound transactions under a short publication lock, run their work outside it, and count admitted transactions. Account switching closes admission and drains existing work before publishing a new activation. Keep remote-application input gates generation-aware and release them on failure or cancellation. Durable saves belong to the account workspace’s process scope.
Alternatives: Hold the lock throughout I/O, or allow unconstrained asynchronous writes with only a final UI check. The first blocks interaction; the second does not protect storage ownership during account changes.
Consequences: Account transitions may wait for admitted work, while ordinary editing admission remains short. Process ownership protects against Activity cancellation, not process death. Transaction and input-gate lifetimes must be reviewed together to avoid self-dependent waits.
ADR-003: Bound local dirty time independently of the network
Status: Accepted and implemented; the upload policy is refined by ADR-008.
Context: A 500 ms debounce alone could postpone local durability for as long as continuous writing prevented a quiet period. Network failures must not decide whether accepted handwriting is saved locally.
Decision: Retain the debounce and add a deadline anchored to the oldest dirty edit: request a save by 2 s, with a measured 5 s durable dirty-age target under healthy I/O. Serialize notebook saves, snapshot generations, and atomically persist content with durable upload intent. Acknowledge only the generation committed. Keep newer edits dirty; coalesce obsolete unsaved work.
Alternatives: Save/upload synchronously on every pen-up, use only quiet-time saving, or acknowledge the latest visible editor state after an older save. These respectively couple input to expensive work, risk indefinite unsaved ink, or falsely mark newer content durable.
Consequences: “Saved locally” is a specific transaction boundary, not cloud confirmation. Failed saves retain ink and expose retry/backpressure. The deadline is not a promise of successful storage while the OS suspends the app or I/O fails.
ADR-004: Use exact packed binary frames with bounded compression
Status: Accepted and implemented after encoding comparison.
Context: Whole-page JSON repeated numeric field names and allocated increasingly large strings. Editable handwriting must retain exact sample precision. A single large stroke must not require one oversized transport record.
Decision: Use the versioned SPIN frame: packed sample columns, a 64 KiB decoded body limit, Deflate level 1 when smaller and raw fallback otherwise. Preserve all finite float32 bits and int64 timestamps; split oversized strokes into bounded segments. Bind exact stored frames by SHA-256 and validate decoded CRC32.
Alternatives: Packed Protobuf fields and Zstd were measured. Lossy rounding, point removal and raster-only content do not preserve exact editable handwriting. The chosen format avoids an added codec runtime while keeping cross-language fixtures and explicit byte/coverage limits.
Consequences: The project owns the format/version/decoder contract. Compression ratios depend on samples and small frames can remain raw. A retry must retain encoded bytes because recompression need not reproduce their hash. Local saving currently repacks changed strokes, including all segments of a changed long stroke; it does not promise fine-grained point-delta storage. Evidence: encoding comparison and frame contract.
ADR-005: Keep note content in databases, with Base64 at text boundaries
Status: Accepted and implemented with verified transport.
Context: Editable note content must stay in databases. Clients start from fresh installations, so existing-user data migrations are outside the supported model. PowerSync’s actual supported transport representation must determine the boundary.
Decision: Separate metadata from stroke payloads. Store frames as PostgreSQL
bytea and local working BLOBs; use Base64 where the sync/JSON text boundary needs
it. Coordinate backend and Android protocol changes. Require fresh installations;
support only binary ink and version-2 page/ToDo metadata. Do not import or convert
inline-JSON notes. Older preference migrations, language enum-name aliases, task
metadata defaults, queued-work compatibility and retired device-only billing
routes are unsupported. Clients and backend must use matching current handlers;
no old-client upgrade ordering is supported.
Alternatives: External object storage, whole-page JSON, assuming native BLOB replication without testing the SDK, or building a legacy-client compatibility framework. These do not satisfy the storage and fresh-installation requirements.
Consequences: No S3/R2 dependency is introduced for note bodies. Base64 adds about one third before framing, and transport copies plus database indexes must be included in storage measurements. Device data deletion still requires explicit authorization. See current-format validation. Evidence: transport and preservation.
ADR-006: Make stroke revisions the conflict unit and preserve order anchors
Status: Accepted and implemented.
Context: A page-wide conflict unit couples unrelated additions. A frame-wide unit would couple strokes merely because they share compression. Rewriting a whole-page manifest for each append would retain growth costs despite binary bytes.
Decision: Use stable stroke/container identities, immutable revisions and bounded reference rows. Store predecessor/clock/installation ordering independently from sample revisions. Compare-and-swap the stroke head only after candidate verification. Retain complete incompatible candidates for explicit review. Keep deleted order anchors while descendants need them.
Alternatives: Last-writer-wins whole pages, frame-level conflicts, a growing manifest row, or renumbering every following stroke after insertion.
Consequences: Concurrent additions can converge deterministically. Transform, erase and Undo preserve order semantics through new head operations. Row/index costs remain real and are measured. Atomic visibility is per complete stroke revision, not necessarily all strokes in one multi-stroke gesture. Contract: revisions and order.
ADR-007: Use selected-page residency and explicit ownership budgets
Status: Accepted and substantially implemented; known read-limit defects remain.
Context: Open notebook caches, history snapshots and native coordinate copies could grow across a session. Freeing arbitrary objects to meet a limit could lose unsaved ink or prevent exact Undo.
Decision: Load the selected page’s samples on demand, retain metadata handles for other pages, use compact history and shared byte/count budgets, and release only saved reloadable content. Account for active session, save snapshots, history and native/editor operation roots. If protected roots exceed the budget, pause new handwriting at a safe boundary and keep recovery actions available.
Alternatives: Keep all visited notebooks decoded, cap only one cache, silently truncate saved ink, or evict dirty data. Those do not preserve the ownership model.
Consequences: Estimates are not a hard process-memory cap. Oversized edits can remove Undo availability while retaining the edit. Bounds need recoverable UI behavior: the meeting whole-load calls and ToDo overview rejection in the known limitations currently violate that expectation and are not accepted examples of graceful backpressure. Evidence: memory accounting.
ADR-008: Persist trigger-scoped upload authorization
Status: Accepted and implemented for notebooks and ToDo handwriting.
Context: Pen-up, autosave and quiet-period timers must not initiate note uploads. Offline triggered work must resume automatically, but reconnect must not upload newer edits that never reached a trigger.
Decision: Keep local saves automatic. Explicit Sync, leaving a page and notebook switching schedule account-wide upload preparation in the background. Persist selected metadata generations and immutable stroke revision/order snapshots. Keep that boundary across interruption/restart. New edits during a flight need a later trigger; remote receiving continues under existing protections.
Alternatives: Suspend all synchronization while editing, upload every local save, or persist only a boolean saying the account is eligible. The boolean would allow later untriggered content to leak into a resumed upload.
Consequences: Saved, authorized, uploaded and confirmed are distinct states. Navigation does not wait for the network, although local save safety can delay it. Authorized snapshots add explicit retention owners. ToDo text/settings keep their existing automatic behavior where dependencies allow; unsaved ToDo drafts keep Save/Cancel semantics. Evidence: trigger-controlled synchronization.
ADR-009: Replace payload receipts with replay fences and owned retention
Status: Accepted and implemented; non-conflict rejection recovery remains incomplete.
Context: Successful receipt bodies retained historical pages and replicated them to devices. Deleting deduplication records by age alone would let old clients replay forgotten operations incorrectly. Conflicts and ambiguous retries still need ink.
Decision: Keep successful outcomes compact. Bind operation identity/digest to installation epoch and sequence, retire completed slots with explicit acknowledgment, and preserve the permanent fence against retired sequences. Reconcile expired epochs before reissuing proven-uncommitted work. Reclaim frames/revisions only when all current, conflict, upload and supported recovery owners have released them.
Alternatives: Retain every successful page upload forever, expire UUID receipts on a timer without a fence, or treat every failed candidate as garbage.
Consequences: Content retention is explainable by ownership, with residual compact
metadata. Exact retries keep their identity; explicit replacement decisions need
new identities. ABANDON releases only its exact staged declaration owner.
Retaining an ERROR candidate is not a complete recovery feature: the READ_ONLY
path identified by review still needs a fresh-operation recovery action.
Evidence: replay ledger and
storage accounting.
ADR-010: Batch transport while retaining per-operation atomicity
Status: Accepted and implemented with separate HTTP/SQL measurements.
Context: Triggered synchronization can accumulate many operations. Fewer HTTP requests can reduce envelope overhead, but combining all work into a giant transaction would increase lock duration and change partial-failure semantics.
Decision: Acquire by count and bytes before parsing a backlog, combine operations in bounded authenticated requests, and preserve each operation’s atomic content/ replay outcome. Reduce redundant access checks where their authority remains valid, without removing per-operation transaction/account fences. Record SQL command counts separately from HTTP request counts.
Alternatives: One request per stroke, loading the entire queue before batching, or claiming SQL savings from HTTP batching alone.
Consequences: Interrupted batches may have committed prefixes; stable retry identities recover them. One operation’s terminal rejection does not roll back unrelated successful operations. Capacity reports retain offered-rate failures rather than implying support for unmeasured loads. Batching is not an architectural throughput guarantee. Evidence: capacity and batch boundaries.