utils/crdt-sync
Krzysztof kuhy Rudnicki aa032c95e4 Drop the gatelock dependency, and actually sync the pool
Two things the pool needed to be real rather than merely correct.

**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:

    ImportError: libtk8.6.so: cannot open shared object file

A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.

The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.

This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.

**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.

install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.

Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.

seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.

freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:56:42 +02:00
..
crdt_sync seed_session: home_guard is a desktop Firebase consumer too 2026-09-06 17:09:46 +02:00
tool Drop the gatelock dependency, and actually sync the pool 2026-09-07 15:56:42 +02:00
.pre-commit-config.yaml Fix the cap hook in CI, and the re-exports its --all-files run exposed 2026-08-22 15:52:59 +02:00
LICENSE Initial implementation: unified CRDT merge scheme + GitHub sync transport 2026-07-05 20:40:09 +02:00
pyproject.toml Grade crdt-sync and gatelock against 3.14 too, not just run on them 2026-08-29 11:47:21 +02:00
README.md Docs: correct stale 'not yet consumed' notes and old install URLs 2026-07-11 23:31:41 +02:00
requirements.txt Keep wait_for_turn under the lint limits, and cover its new paths 2026-08-30 15:06:47 +02:00

crdt-sync

Shared CRDT merge scheme + GitHub-Contents-API sync transport, extracted for reuse across four personal apps that each need cross-device sync: todo, diet_guard, wake_alarm, and screen-locker.

Why

Two of those apps (diet_guard, and independently todo) had already converged on the same shape of solution: treat a private GitHub repo as dumb file storage via the REST Contents API (not a git clone), have each device push its own file, and merge on read. diet_guard's merge logic (entry-id-keyed, tombstone-wins) and todo's merge logic (sqlite_crdt's per-field HLC last-writer-wins) solve the same underlying problem — convergent merge without a central server — for two different data shapes (append-only logs vs. mutable per-field records). crdt-sync generalizes both into one scheme so all four apps can share it, at the cost of todo's notes and wake_alarm's single wake-time value both running through CRDT machinery their own data doesn't strictly need — an explicit consistency-over-leanness tradeoff.

The merge scheme

A generic LWW-map-with-sticky-remove, built from three pieces:

  • Hlc — a Hybrid Logical Clock: (wall_time_ms, counter, node_id), totally ordered by tuple comparison (node_id breaks ties between two clocks with identical wall time and counter, so no two distinct devices' clocks ever compare equal). Hlc.new_tick(node_id, previous) advances off wall-clock time, or bumps counter when the wall clock hasn't moved past previous — the standard HLC construction, guaranteeing monotonicity even across clock skew or two ticks in the same millisecond.
  • Record — id, a fields map of name -> (value, Hlc), and a sticky deleted flag. merge_field keeps whichever side's Hlc is greater, per field — this is what lets two devices concurrently edit different fields of the same record (or the same field) and converge deterministically without a central authority. merge_record's deleted is not itself per-field LWW: it's a.deleted or b.deleted, monotonic and one-directional. This preserves a guarantee diet_guard's original tombstone scheme already relied on — a delete can never be silently undone by merging in an older non-deleted copy pulled from a device that hasn't seen the delete yet.
  • Log = dict[id, Record]. merge_logs takes the union of ids and merge_records the ones present on both sides.

All three merges are commutative and idempotent (provable directly from max-by-Hlc and boolean-or), so pull order between devices never matters and a repeated sync tick is a no-op — see crdt_sync/tests/test_log.py's property-style cases for the concrete proof.

Per-app adapters are out of scope for this library. Translating a Note, a FoodEntry, a workout entry, or wake_alarm's {hour, minute} value to and from Record is each app's own job — keeping this library domain-agnostic is what makes it actually shared, rather than shaped around one app's schema.

Install

pip install "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.2.0#subdirectory=crdt-sync"

Usage

from crdt_sync import GitHubSyncClient, Hlc, Record, sync_log

node_id = "pc"
clock = Hlc.new_tick(node_id)
record = Record(id="abc123", fields={"text": ("buy milk", clock)}, deleted=False)

client = GitHubSyncClient(owner="kuhyx", repo="my-app-sync", token=token)
result = sync_log(
    client=client,
    device_id=node_id,
    path_prefix="devices",
    local_log={record.id: record},
    encode=lambda log: json.dumps({k: r.to_dict() for k, r in log.items()}),
    decode=lambda text: {
        k: Record.from_dict(v) for k, v in json.loads(text).items()
    },
)

sync_log pulls every other device's last-pushed log from <path_prefix>/<other-device-id>/..., merges each into the local log with merge_logs, then pushes this device's own merged result back up — the same pull-all/merge/push-own pattern diet_guard's _sync.py already used, made domain-agnostic via the encode/decode callbacks so callers keep their own on-disk JSON shape instead of crdt-sync dictating one.

Local persistence

For persisting a Log to disk, crdt_sync ships load/save helpers that share a canonical on-disk JSON shape with the Dart crdt_sync_dart.LogStore, so a log written by one language parses in the other:

from crdt_sync import dump_log, load_log, read_log, write_log

write_log(path, log)          # atomic temp-file-then-rename
log = read_log(path)          # empty on a missing or corrupt file
text = dump_log(log)          # -> canonical JSON string
log = load_log(text)          # <- parse it back

These are load/save only. The Dart side additionally offers a reactive LogStore (a change stream to drive a live UI); that's a Flutter-only convenience and has no Python equivalent by design — Python consumers here are headless sync ticks.

Development

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest
pre-commit install && pre-commit install --hook-type pre-push