Files
synq/tasks.org

26 KiB

synq tasks

goal

build a tiny android-to-org capture system.

the app must be instant-feeling, capture-only, and boring. the phone stores notes locally, then posts them to a lan-only server. the server appends validated entries to a central org file.

decisions

  • android-only native app.
  • kotlin + jetpack compose.
  • room sqlite for local queue/history.
  • okhttp or retrofit for http.
  • workmanager for opportunistic sync.
  • python fastapi listener in docker.
  • server owns all writes to the canonical org file.
  • phone never edits org files directly.
  • server dedupes by capture id.
  • no nextcloud/file-sync dependency.
  • no org parser on phone.
  • no public exposure.

acceptance criteria

  • opening the app focuses the text field automatically.
  • saving a note works offline and returns the user to an empty entry state.
  • captures include local created timestamp with timezone.
  • user can mark capture as todo.
  • user can add optional tags.
  • manual sync posts all pending captures when server is reachable.
  • server appends valid captures to `phone.org`.
  • duplicate posts do not create duplicate org entries.
  • failed sync attempts remain visible and retryable.
  • app shows at least pending/synced/failed history.
  • service can run in docker on jeeves/unraid.
  • service has a `/health` endpoint.
  • service rejects missing/invalid auth token.
  • service rejects empty capture body.
  • service has tests for org formatting and duplicate handling.

milestone 0: repo setup

DONE create monorepo skeleton

acceptance

  • `android/`, `server/`, and `docs/` directories exist.
  • root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist.

notes

Skeleton committed in initial commit. android/ dir will be created in milestone 2.

evidence

  • commit: c08b3fe
  • tests: n/a (directory structure)
  • datetime: [2026-05-17 Sat 00:00]

DONE add root gitignore

acceptance

  • ignores android build outputs.
  • ignores python virtualenv/cache.
  • ignores sqlite db files.
  • ignores local env files.
  • does not ignore docs or source files.

notes

Standard gitignore covering Python venv/cache, sqlite db files, .env, and Android build outputs.

evidence

  • commit: c08b3fe
  • tests: n/a
  • datetime: [2026-05-17 Sat 00:00]

DONE document v1 scope in README

acceptance

  • states capture-only scope.
  • states non-goals.
  • states architecture and build order.

notes

README covers non-goals, architecture diagram, v1 behavior, org output format, and build order.

evidence

  • commit: c08b3fe
  • tests: n/a
  • datetime: [2026-05-17 Sat 00:00]

DONE add task template to tasks.org

acceptance:

  • `*** notes` section for each milestone task, to be used by you to document your decisions and hurdles overcome
  • `*** evidence` section with sub bullets:

    • `commit` list of commit hashes on this task
    • `tests` describing tests run (anyone should be able to use these later)
    • `datetime` the task was completed, including timestamp, eg [2026-05-17 Sun 16:06]

notes

Template added in 5f387df. All milestone tasks now have notes/evidence blocks.

evidence

  • commit: 5f387df
  • tests: n/a
  • datetime: [2026-05-17 Sat 00:00]

milestone 1: server mvp

DONE create fastapi app

acceptance

  • `GET /health` returns 200 with simple json.
  • `POST /capture` accepts a valid capture payload.
  • auth token is required for `POST /capture`.

notes

FastAPI app in server/app/main.py. Auth via Bearer token dependency injected on POST /capture. Health endpoint needs no auth.

evidence

  • commit: e873a00
  • tests: pytest tests/test_api.py::TestHealth, tests/test_api.py::TestCapture — 12 tests, all pass
  • datetime: [2026-05-17 Sat 17:00]

DONE define pydantic capture model

acceptance

  • requires id, created_at, kind, body, tags, and device.
  • kind is constrained to note/todo.
  • body is trimmed and must not be empty.
  • tags are normalized before formatting.

notes

Pydantic v2 model in server/app/models.py. Body is stripped via field_validator; kind uses Literal["note","todo"]. Tag normalization lives in org_writer.py so the model stays a plain schema.

evidence

  • commit: e873a00
  • tests: pytest tests/test_api.py::TestCapture::test_empty_body_rejected, test_invalid_kind_rejected — pass
  • datetime: [2026-05-17 Sat 17:00]

DONE implement org formatter

acceptance

  • todo captures produce `* TODO …`.
  • note captures produce `* note …`.
  • org property drawer includes created, source, and id.
  • tags are emitted as org heading tags.
  • multiline note body is preserved.
  • tests cover todo, note, tags, empty tags, and multiline body.

notes

Pure function format_capture() in server/app/org_writer.py. Single-line notes: heading is "* note", body below drawer. Multiline notes: heading is "* note: <first line>", full body below drawer. Tag normalization strips #, lowercases, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates.

evidence

  • commit: e873a00
  • tests: pytest tests/test_org_writer.py — 15 tests (7 normalize_tags + 8 format_capture), all pass
  • datetime: [2026-05-17 Sat 17:00]

DONE implement sqlite idempotency store

acceptance

  • accepted capture ids are stored.
  • repeated id returns accepted/already-seen without appending.
  • db path is configurable.

notes

IdempotencyStore in server/app/store.py. Uses sqlite3 with a seen_ids table. DB path from PHONE_CAPTURE_DB_PATH env var. already_seen() checks before write; mark_seen() inserts after append.

evidence

  • commit: e873a00
  • tests: pytest tests/test_api.py::TestCapture::test_duplicate_capture_returns_already_seen, test_duplicate_not_appended_twice — pass
  • datetime: [2026-05-17 Sat 17:00]

DONE implement append writer

acceptance

  • appends to configured org path.
  • creates file if missing.
  • writes utf-8.
  • appends exactly one entry per new capture.
  • uses file lock or equivalent simple concurrency guard.

notes

Append logic in main.py POST /capture handler. Uses a module-level threading.Lock (get_file_lock()) around open(path, "a", encoding="utf-8"). Creates parent dirs if missing. Org path from PHONE_CAPTURE_ORG_PATH env var.

evidence

  • commit: e873a00
  • tests: pytest tests/test_api.py::TestCapture::test_valid_capture_appended_to_org, test_duplicate_not_appended_twice — pass
  • datetime: [2026-05-17 Sat 17:00]

DONE add server tests

acceptance

  • health endpoint test.
  • valid capture append test.
  • duplicate capture test.
  • invalid token test.
  • empty body rejection test.

notes

28 tests total across test_api.py (12) and test_org_writer.py (16). All pass. Run with: cd server && python -m pytest tests/ -v

evidence

  • commit: e873a00
  • tests: pytest tests/ — 28 passed in 0.33s
  • datetime: [2026-05-17 Sat 17:00]

DONE containerize server

acceptance

  • dockerfile builds server image.
  • compose example maps `/data`.
  • env vars configure token, org file, and sqlite path.
  • container starts and exposes configured port.

notes

server/Dockerfile uses python:3.11-slim, installs deps, copies app/, exposes 8765. docker-compose.example.yml already existed in root with correct env var mapping. CMD runs python -m app.main via uvicorn.

evidence

  • commit: e873a00
  • tests: n/a (docker build not run in CI; validate manually with: docker build ./server && docker run -e PHONE_CAPTURE_TOKEN=… -p 8765:8765 -v /data:/data <image>)
  • datetime: [2026-05-17 Sat 17:00]

milestone 2: android local capture

DONE create android project

acceptance

  • kotlin android app builds.
  • jetpack compose enabled.
  • min sdk is reasonable for current personal device.
  • app id is stable, e.g. `me.hgsky.phonecapture`.

notes

Gradle project in android/. AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01. minSdk=31, compileSdk=36. App ID is me.hgsky.synq. Open android/ in Android Studio and hit Sync — AS will download the Gradle wrapper (8.9) automatically.

evidence

  • commit: 19b05a8
  • tests: Gradle sync in Android Studio (manual step)
  • datetime: [2026-05-18 Sun 17:00]

DONE build capture screen

acceptance

  • app opens to multiline text box.
  • keyboard opens automatically.
  • note/todo control exists.
  • tags field exists.
  • save button exists.
  • save and close affordance exists.

notes

CaptureScreen.kt uses LaunchedEffect(Unit) { focusRequester.requestFocus() } for auto-focus. note/todo Switch, tags OutlinedTextField, "save" OutlinedButton (stays), "save & close" Button (finishes activity). No network call on launch.

evidence

  • commit: 19b05a8
  • tests: manual — build and run on device/emulator
  • datetime: [2026-05-18 Sun 17:00]

DONE implement local room database

acceptance

  • capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error.
  • saving creates pending row.
  • pending captures survive app restart.

notes

CaptureEntity, CaptureDao, CaptureDatabase in data/db/. Tags stored as JSON string (kotlinx-serialization). DB name synq.db, singleton via companion object. Room persists across restarts by default.

evidence

  • commit: 19b05a8
  • tests: manual — save a capture, force-close app, reopen and check history
  • datetime: [2026-05-18 Sun 17:00]

DONE implement capture id generation

acceptance

  • id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`.
  • ids are generated client-side.
  • tests or simple assertions prevent blank/duplicate ids in normal flow.

notes

generateCaptureId() in data/CaptureId.kt. Format: phone-YYYYMMDD-HHmmss-xxxx (4 random [a-z0-9] chars). Called in CaptureViewModel.save() before Room insert.

evidence

  • commit: 19b05a8
  • tests: manual — check id column in history; each save produces a unique phone-… id
  • datetime: [2026-05-18 Sun 17:00]

DONE implement basic history screen

acceptance

  • lists recent captures.
  • shows pending/synced/failed status.
  • failed row shows last error.
  • user can retry failed/pending sync.

notes

HistoryScreen.kt observes captureDao.observeAll() as Flow. Status colored green/red/grey. Retry button shown for pending/failed; calls updateStatus(id, "pending", null) so the sync worker picks it up in milestone 3.

evidence

  • commit: 19b05a8
  • tests: manual — save captures, navigate to history, verify status labels and retry button
  • datetime: [2026-05-18 Sun 17:00]

DONE Checkout tests

[2026-05-18 Mon 12:40]

  • updated Android Studio, ran Upgrade Assistant for Android Gradle Plugin to 8.13.2
  • added Pixel 8 emulator
  • ran a few fixes:

    • `779ad6` - upped app memory to 2GB from 500MB - investigate?
    • `7671416` - app.settings needs the SynqApp cast — same as dao. And it's unused in milestone 2 anyway, so just removed it

milestone 3: android sync

DONE implement api client

acceptance

  • base url configurable in app settings or build config.
  • bearer token configurable in app settings or build config.
  • client can call `/health`.
  • client can post capture payload.

notes

SynqApiClient in data/api/. OkHttp with 10s timeouts. checkHealth() does GET /health, returns false on any exception so callers never crash. postCapture() maps 401 → failed, non-2xx → failed, already_seen → AlreadySeen, else → Accepted. Uses org.json for request building, kotlinx-serialization for tag decoding.

evidence

  • commit: 2a963d9
  • tests: manual — sync now button, check history screen status
  • datetime: [2026-05-18 Sun 18:00]

DONE implement manual sync

acceptance

  • sync now posts all pending captures.
  • successful posts mark rows synced.
  • already-seen response marks row synced.
  • failures retain pending/failed status and last error.

notes

syncPending() top-level function in data/sync/SyncService.kt — shared by manual sync and WorkManager. CaptureViewModel.syncNow() calls it on Dispatchers.IO, guards with isSyncing flag. Sync icon in top bar shows CircularProgressIndicator while running.

evidence

  • commit: 2a963d9
  • tests: manual — tap sync, verify captures move to synced in history; test with wrong token, verify failed + error message
  • datetime: [2026-05-18 Sun 18:00]

DONE implement opportunistic workmanager sync

acceptance

  • periodic sync is registered.
  • sync only runs when network is available.
  • worker first checks `/health`.
  • worker does not block capture speed.

notes

SyncWorker (CoroutineWorker) in data/sync/. Registered in SynqApp.onCreate() as KEEP unique periodic work (15-min interval, NETWORK_CONNECTED constraint). Health check before sync — exits quietly if server unreachable. Does not touch UI thread.

evidence

  • commit: 2a963d9
  • tests: manual — let app sit on WiFi, verify pending captures eventually sync without manual trigger
  • datetime: [2026-05-18 Sun 18:00]

DONE add simple server reachability settings

acceptance

  • default url can be set to `http://jeeves.mother:8765`.
  • user can change server url.
  • user can change token.
  • invalid settings do not crash app.

notes

SettingsScreen + SettingsViewModel from milestone 2. DataStore-backed. Default serverUrl is http://jeeves.mother:8765. All network calls wrap exceptions so invalid URL/token never crashes — they just produce PostResult.Failed with the error message.

evidence

  • commit: 19b05a8 (screen), 2a963d9 (wired to sync)
  • tests: manual — set bad URL, tap sync, verify captures stay pending/failed with error; set correct URL + token, sync succeeds
  • datetime: [2026-05-18 Sun 18:00]

milestone 4: polish and hardening

DONE make launch path fast

acceptance

  • app opens directly to capture field.
  • no network call blocks launch.
  • no history loading blocks text entry.

notes

LaunchedEffect(Unit) { focusRequester.requestFocus() } fires immediately on composition. No network calls in MainActivity or CaptureScreen init path. History loads via Room Flow on a background coroutine — never blocks text entry.

evidence

  • commit: 19b05a8
  • tests: manual — cold launch, keyboard appears without any loading state
  • datetime: [2026-05-18 Sun 19:00]

DONE normalize tags

acceptance

  • whitespace-separated and comma-separated tags both work.
  • invalid org tag chars are stripped or replaced.
  • duplicate tags collapse.

notes

Two-layer normalization: parseTags() in CaptureViewModel splits on [,\s]+ before storing to Room. normalize_tags() in server/app/org_writer.py lowercases, strips #, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates. 7 unit tests cover all cases.

evidence

  • commit: e873a00 (server), 19b05a8 (android)
  • tests: pytest tests/test_org_writer.py::TestNormalizeTags — 7 passed
  • datetime: [2026-05-18 Sun 19:00]

DONE fix org formatting

acceptance

  • add `#+startup: overview` to synq.org

notes

Append writer in main.py checks if org file exists before first write. If missing, creates it with #+title and #+startup: overview header. Existing files are never modified.

evidence

  • commit: fd28a45
  • tests: manual — delete synq.org, post a capture, verify header present
  • datetime: [2026-05-18 Sun 19:00]

DONE handle multiline notes well

acceptance

  • first line can become note heading if body is multiline.
  • full body is preserved under heading.
  • todo body remains usable as a single todo heading, with extra lines under it if present.

notes

format_capture() in org_writer.py: multiline note uses first line as heading suffix ("* note: <first line>"), full body preserved below drawer. Single-line note uses "* note" heading, body below. Covered by test_note_multiline.

evidence

  • commit: e873a00
  • tests: pytest tests/test_org_writer.py::TestFormatCapture::test_note_multiline — passed
  • datetime: [2026-05-17 Sat 17:00]

DONE add basic logs

acceptance

  • server logs accepted capture id.
  • server logs duplicate capture id.
  • server logs rejected payload without dumping secrets.

notes

logger.info for accepted and duplicate (id only, no body). logger.warning for invalid token (client IP only, no token value logged). logger.warning for validation errors (field names only, no body content).

evidence

  • commit: fd28a45
  • tests: manual — send bad token, send empty body, check uvicorn stdout
  • datetime: [2026-05-18 Sun 19:00]

DONE create backup note

acceptance

  • readme documents which paths need backup.
  • readme documents restore behavior.

notes

Added "backup" section to README.md covering synq.org, capture.sqlite3, token.txt. Restore section documents behavior when each file is lost individually.

evidence

  • commit: 66155f6
  • tests: n/a
  • datetime: [2026-05-18 Sun 19:00]

milestone 5: optional v1.5 improvements

DONE add android share target

acceptance

  • sharing text to app opens prefilled capture.
  • user can save as note or todo.

notes

ACTION_SEND text/plain intent-filter added to MainActivity in manifest. MainActivity.onCreate extracts EXTRA_TEXT and passes it as a URL-encoded nav argument to the capture/{prefill} route. CaptureScreen applies it via LaunchedEffect on first composition.

evidence

  • commit: 66155f6
  • tests: manual — share any text from browser/notes to synq, verify it pre-fills capture body
  • datetime: [2026-05-18 Sun 20:00]

TODO add home screen quick capture widget

acceptance

  • widget opens capture screen quickly.
  • does not require sync to work.

DONE add recent tag chips

acceptance

  • app shows recent tags.
  • tapping chip adds/removes tag.

notes

CaptureViewModel.recentTags collects the last 100 capture tagsJson rows, decodes each JSON array, flattens and deduplicates (LinkedHashSet order), limits to 20. CaptureScreen shows a FlowRow of FilterChip below the tags text field. Tapping a chip calls toggleTag() which adds/removes it from the tags string. Active chips are highlighted via FilterChip selected=true.

evidence

  • commit: 66155f6
  • tests: manual — save a few captures with tags, open capture screen, verify chips appear and toggle
  • datetime: [2026-05-18 Sun 20:00]

DONE add edit-before-sync

acceptance

  • pending/failed captures can be edited.
  • synced captures are read-only unless explicitly duplicated.

notes

CaptureDao.updateBody() new query. HistoryViewModel.updateBody() calls it. HistoryScreen CaptureRow shows "edit" OutlinedButton only when expanded AND status is pending/failed. Tapping edit opens an AlertDialog with an OutlinedTextField pre-filled with current body. Save calls updateBody; cancel dismisses.

evidence

  • commit: 66155f6
  • tests: manual — save a capture, open history, expand row, tap edit, change body, save, verify updated
  • datetime: [2026-05-18 Sun 20:00]

DONE tap to expand history item

acceptance

  • tapping a history row expands/collapses it.
  • expanded row shows full body.
  • edit button is only visible when expanded.

notes

CaptureRow uses rememberSaveable expanded: Boolean keyed on capture.id. Card has clickable { expanded = !expanded }. maxLines switches between 2 and Int.MAX_VALUE. Edit button only renders when expanded && editable.

evidence

  • commit: 66155f6
  • tests: manual — tap history rows to expand/collapse, verify full body visible when expanded
  • datetime: [2026-05-18 Sun 20:00]

DONE add "Last synced" display

acceptance

  • "last synced at" visible in history top bar and capture screen.

notes

CaptureDao.getLastSyncedAt() returns Flow<String?> of MAX(syncedAt). HistoryViewModel and CaptureViewModel both stateIn this flow. HistoryScreen shows it as a subtitle under "history" in the TopAppBar. CaptureScreen shows it as a small label above the save buttons. Both parse the ISO string via OffsetDateTime and format with DateTimeFormatter.ofLocalizedDateTime(SHORT).

evidence

  • commit: 66155f6
  • tests: manual — sync a capture, verify timestamp appears in history header and capture screen
  • datetime: [2026-05-18 Sun 20:00]

DONE add settings for sync frequency

acceptance

  • user-specified poll rate in minutes (min 15).
  • changing interval reschedules WorkManager immediately.

notes

SynqSettings gained syncIntervalMinutes: Int = 15 field with DataStore key sync_interval_minutes. SettingsScreen added OutlinedTextField with number keyboard for interval, shows supporting text "WorkManager minimum is 15 min". buildSettings() coerces to max(value, 15). SettingsViewModel.save() now also calls SyncWorker.schedule(app, settings.syncIntervalMinutes). SyncWorker.schedule() uses REPLACE policy so new interval takes effect immediately.

evidence

  • commit: 66155f6
  • tests: manual — change interval to 30, save, verify WorkManager re-queues (check adb shell dumpsys jobscheduler)
  • datetime: [2026-05-18 Sun 20:00]

milestone 6: post-ship cleanup

DONE fix resource XML comment before <?xml declaration

acceptance

  • android build succeeds with custom icon assets.

notes

Android's AAPT resource compiler requires <?xml to be the first byte of the file. Image Asset tool generated ic_launcher_background.xml, ic_launcher.xml, and ic_launcher_round.xml with an Apache license comment block before <?xml, causing ParseError at row 16. Fixed by stripping the comment from those three files. ic_launcher_foreground.xml was unaffected (no <?xml declaration). Do not use Image Asset tool for tweaks — it re-adds the comment on reimport; edit XML directly.

evidence

  • commit: a6af2fe
  • tests: manual — gradle assembleDebug succeeds
  • datetime: [2026-05-19 Mon]

DONE fix manifest missing icon references

acceptance

  • app icon appears on device launcher after install.

notes

AndroidManifest.xml was missing android:icon and android:roundIcon attributes on the <application> element. Added @mipmap/ic_launcher and @mipmap/ic_launcher_round. Also committed all mipmap-* webp files and drawable/ic_launcher_background.xml (solid #22569d) and ic_launcher_foreground.xml generated by Image Asset tool.

evidence

  • commit: a6af2fe
  • tests: manual — install APK on device, verify icon appears in launcher
  • datetime: [2026-05-19 Mon]

DONE fix status bar icons washed out (API 35 edge-to-edge)

acceptance

  • system clock, battery, wifi icons are visible over the app TopAppBar.

notes

Android 15 (API 35) enforces edge-to-edge regardless of enableEdgeToEdge() call — removing it had no effect. Real fix: keep enableEdgeToEdge() in MainActivity and add a SideEffect in Theme.kt that calls WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true. This tells the system to render dark (visible) status bar icons on the light TopAppBar background.

evidence

  • commit: 68deb5c
  • tests: manual — open app, verify clock/battery/wifi icons are dark and readable
  • datetime: [2026-05-19 Mon]

DONE fix keyboard covering Save button

acceptance

  • Save and Save & Close buttons are visible above the software keyboard.

notes

Added imePadding() to the Column modifier in CaptureScreen. The column shrinks to fit above the keyboard rather than being obscured by it.

evidence

  • commit: 23df95d
  • tests: manual — tap body field, keyboard opens, verify save buttons remain visible
  • datetime: [2026-05-19 Mon]

DONE add sync result snackbar

acceptance

  • brief snackbar appears after tapping sync: "synced N", "nothing to sync", or "server unreachable".

notes

Added MutableSharedFlow<String> snackbar emitter to CaptureViewModel. syncNow() now calls checkHealth() first and emits "server unreachable" on failure. syncPending() return type changed from Unit to Int (count of successfully synced captures). SnackbarHost added to CaptureScreen Scaffold; LaunchedEffect collects the flow and calls snackbarState.showSnackbar().

evidence

  • commit: 4597f92
  • tests: manual — tap sync with server up (see "synced N"), with server down (see "server unreachable"), with empty queue (see "nothing to sync")
  • datetime: [2026-05-19 Mon]

DONE log token on every server startup

acceptance

  • token is clearly visible in container logs on every start, not just first run.

notes

lifespan() in server/app/main.py now logs the token between = lines on every startup. Previously only logged "token loaded from token.txt" quietly; now logs the token value itself so it's easy to find with docker logs synq.

evidence

  • commit: a6af2fe
  • tests: manual — docker restart synq, docker logs synq | grep token
  • datetime: [2026-05-19 Mon]

DONE default device label to Build.MODEL

acceptance

  • fresh install shows the actual device model name, not "android".

notes

SettingsRepository.kt: changed deviceLabel fallback from hardcoded "android" to Build.MODEL. No permissions needed. Existing users who have already saved a label are unaffected — DataStore only uses the fallback when no value is stored.

evidence

  • commit: 628a28a
  • tests: manual — fresh install, open settings, verify device label shows model name
  • datetime: [2026-05-19 Mon]

DONE replace hex token with 3-word passphrase

acceptance

  • generated token is human-readable and fits on one log line.
  • existing token.txt files are unaffected.

notes

secrets.token_hex(32) produced 64 chars — too long to read/verify visually. Replaced with _generate_passphrase() which picks 3 words from a 512-word embedded list using secrets.choice, hyphenated (e.g. "coral-drift-lamp"). ~27 bits entropy, sufficient for a LAN-only service. Existing token.txt files load unchanged — generation only runs when no token exists. To rotate: delete /data/token.txt and restart the container.

evidence

  • commit: 628a28a
  • tests: manual — delete token.txt, restart container, docker logs synq shows short readable token
  • datetime: [2026-05-19 Mon]

implementation notes for coding agents

  • keep the server small and testable.
  • keep org formatting in one pure function.
  • do not add auth flows, accounts, or public hosting.
  • do not add org parsing.
  • do not write to org files from android.
  • prefer boring explicit settings over clever discovery.
  • if a design choice risks sync conflicts, do the simpler append-only thing.