Sync Model
The LTG synchronizes state across three scopes. Each scope has its own transport, its own authentication assumptions, and its own conflict policy. Object-class authority (cloud-only / local-only / conflict-aware) is declared per type.
Three sync scopes
Scope A — Local devices on one Lenser account
| Aspect | Decision |
|---|---|
| Transport | 127.0.0.1 HTTP/WS, mDNS for discovery |
| Bind by default | 127.0.0.1:38080 only — never 0.0.0.0 |
| Authentication | Ed25519 device signature + Supabase JWT |
| Authorization | Same lenser_id AND trust_level ∈ {approved, trusted} |
| Liveness | mDNS + 30 s heartbeat against cloud |
| Conflict policy | Leader-elects (oldest trusted device wins write coordination) |
Scope B — Tailscale / private-network devices
| Aspect | Decision |
|---|---|
| Transport | WireGuard via Tailscale (CGNAT 100.64.0.0/10); generalizable to any private interface |
| Bind | Off by default. Requires lf gateway serve --tailscale AND a passing lf gateway doctor. |
| Authentication | Ed25519 device signature + Supabase JWT — Tailscale identity is ignored for authn |
| Authorization | Same as Scope A |
| Conflict policy | Same as Scope A |
The single existing Tailscale reference in libs/utils/dom/src/lib/authReturnUrl.ts is documentary only; the runtime detector lives in libs/infra/gateway/src/lib/tailscale-detector.ts.
Scope C — Cloud sync
| Aspect | Decision |
|---|---|
| Transport | Supabase REST / RPC / Realtime (postgres_changes + broadcast) |
| Authentication | Supabase JWT for reads; Supabase JWT + signed envelope for trust-state mutations |
| Authorization | RLS deny-by-default + SECURITY DEFINER RPCs |
| Conflict policy | Per-object-class merge function (default LWW + vector clock) |
Object class authority
Every sync candidate belongs to exactly one object class. Authority is declared in libs/infra/gateway/src/lib/object-classes.ts.
Cloud-authoritative (read-only on edges)
| Class | Source of truth | Local representation |
|---|---|---|
xp_total | xp.totals | Pull-only cache |
trust_evaluation | execution.trust_evaluations | Pull-only cache |
battle_result | battles.battles (status='published') | Pull-only cache |
policy | agents.workspace_settings | Pull-only cache |
budget | agents.workspace_settings | Pull-only cache |
kill_switch | agents.workspace_settings.global_kill_switch | Pull-only; daemon polls every 10 s |
dark_launch | agents.workspace_settings.dark_launch_* | Pull-only |
ai_catalog | ai.providers / ai.models | Pull-only cache, daily refresh |
Edges may not push these. Attempts to push are rejected at the daemon and at the RPC.
Local-authoritative (never sync raw)
| Class | Where it lives | Why local-only |
|---|---|---|
byok_key | OS keychain (via libs/utils/keychain) or env | Never written to DB; documented in libs/providers/src/lib/byok-key-resolver.ts |
local_battle | user runtime local-battles/*.json (encrypted) | AES-256-GCM envelope; passphrase from env |
scratchpad_draft | Daemon process memory | Ephemeral |
keychain_entry | OS keychain | Never leaves the device |
private_key | OS keychain | Never leaves the device |
If raw cloud sync is ever attempted on these classes, the daemon refuses with local_only_class.
Conflict-aware (bidirectional)
| Class | Cloud table(s) | Default merge policy |
|---|---|---|
agent_config | agents.ai_lensers | LWW per field with vector clock |
agent_team_graph | agents.teams, agents.team_edges | LWW per edge with vector clock |
workflow_definition | lenses.workflows, lenses.versions | LWW per version with created_at tiebreak |
lens_draft | lenses.lenses (draft state only) | LWW per field |
runner_metadata | execution.runner_adapters (config JSON only) | LWW per field |
non_secret_pref | lensers.preferences | LWW per field |
automation_registry_entry | none (cloud mirror table TBD) | LWW per entry |
Source of truth
Object class metadata is declared once in libs/infra/gateway/src/lib/object-classes.ts. The TypeScript registry distinguishes:
objectClassesByAuthority('cloud')— read-only on edges; pushes rejected withcloud_authoritative.objectClassesByAuthority('local')— never enters the outbox; pushes rejected withlocal_only_class.objectClassesByAuthority('conflict_aware')— bidirectional sync.pushableObjectClasses()— equalsconflict_aware. Only these are eligible for the outbox.pullableObjectClasses()— equalscloud ∪ conflict_aware. The set the daemon's pull loop iterates over.
The same partitioning is enforced in devices.fn_sync_push so that the database and the daemon agree on what is pushable.
Default merge function
The default merge for conflict-aware classes lives in libs/infra/gateway/src/lib/conflict-resolver.ts and behaves as follows:
- Vector-clock causality wins. If
Ahappens-beforeB(every component ofA≤ corresponding component ofB), takeB. IfBhappens-beforeA, takeA. Equal clocks → takeA(deterministic). - Concurrent edits → sum-of-clock LWW with lexicographic tiebreak. Sum each entry's vector clock values; the higher sum wins. Equal sums tiebreak by lexicographic device id.
- Hard conflict → emit a conflict row. Identical vector clocks AND identical device ids on differing payloads is a programmer error and should never happen in practice; the resolver returns
{ kind: 'conflict' }and the daemon surfaces it.
Per-class overrides may add structural merge (e.g. workflow step diffs) by registering an alternative merge function — track this through object-classes.ts. Defaults stay LWW + vector clock.
Conflicts that the merge function cannot auto-resolve (e.g. structured conflict in workflow steps) are surfaced via:
lf gateway sync status --conflicts- The web Devices feature in
libs/features/devices
Outbox + watermarks
devices.sync_outbox
CREATE TABLE devices.sync_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lenser_id UUID NOT NULL REFERENCES lensers.profiles(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES devices.registered_devices(id) ON DELETE CASCADE,
object_class TEXT NOT NULL,
object_id TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('upsert','delete')),
payload JSONB NOT NULL,
vclock JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Writers: device daemons (via fn_sync_push). Readers: cloud-side merge job (via fn_sync_apply_outbox / cron). Retention: 30 days, then archived to audit.events.
devices.sync_watermarks
CREATE TABLE devices.sync_watermarks (
lenser_id UUID NOT NULL REFERENCES lensers.profiles(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES devices.registered_devices(id) ON DELETE CASCADE,
object_class TEXT NOT NULL,
watermark TIMESTAMPTZ NOT NULL DEFAULT '-infinity',
PRIMARY KEY (device_id, object_class)
);Updated by fn_sync_pull after successfully returning rows newer than watermark. The pull is idempotent — replaying with the same watermark returns the same set.
devices.nonce_cache
CREATE TABLE devices.nonce_cache (
nonce TEXT PRIMARY KEY,
device_id UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);10-minute retention. Cleaned by cron. Replaying a nonce within the window is rejected as nonce_replay.
Sync RPCs
| RPC | Purpose | Caller |
|---|---|---|
devices.fn_sync_push(p_envelope JSONB) | Verify envelope, walk body.entries[], apply each entry per its class. | Daemon |
devices.fn_sync_pull(p_object_classes TEXT[], p_limit INT, p_envelope JSONB) | Verify envelope, return rows newer than watermark for each class, advance watermarks atomically. | Daemon |
devices.fn_sync_status() | Return per-class watermarks + outbox depth + last error. | Daemon, CLI |
devices.fn_sync_resolve_conflict(p_conflict_id UUID, p_winner JSONB) | Apply user resolution from lf gateway sync status or web UI. | CLI, web |
All sync RPCs are SECURITY DEFINER with SET search_path = devices, lensers, public, extensions, granted to authenticated only.
Leader election
For local-mesh write coordination (e.g. who flushes the outbox if multiple devices are online), devices.peer_leases holds time-bounded leases:
CREATE TABLE devices.peer_leases (
lease_kind TEXT NOT NULL, -- e.g. 'sync_flush'
lenser_id UUID NOT NULL,
device_id UUID NOT NULL,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (lease_kind, lenser_id)
);devices.fn_acquire_leader_lease(p_kind TEXT, p_lease_seconds INT) returns the current holder; if expired, atomically grants to the caller. Tiebreak: oldest trust_level='trusted' device wins; if no trusted device, oldest approved device wins.
Failure modes and recovery
| Failure | Behavior |
|---|---|
| Outbox push fails | Retry with exponential backoff; daemon surfaces via gateway_status='degraded'. Outbox is durable; nothing is lost. |
| Pull fails | Watermark is not advanced; next pull retries. |
| Nonce replay | RPC returns nonce_replay; daemon discards envelope, regenerates nonce. |
| Clock skew detected | Daemon refuses to start (> 5 min); lf gateway doctor reports. |
| Conflict cannot auto-merge | Conflict row written; lf gateway sync status --conflicts lists it; user resolves via CLI or web. |
| Local-only class push attempted | Daemon refuses with local_only_class; logged to audit chain. |