Skip to content

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

AspectDecision
Transport127.0.0.1 HTTP/WS, mDNS for discovery
Bind by default127.0.0.1:38080 only — never 0.0.0.0
AuthenticationEd25519 device signature + Supabase JWT
AuthorizationSame lenser_id AND trust_level ∈ {approved, trusted}
LivenessmDNS + 30 s heartbeat against cloud
Conflict policyLeader-elects (oldest trusted device wins write coordination)

Scope B — Tailscale / private-network devices

AspectDecision
TransportWireGuard via Tailscale (CGNAT 100.64.0.0/10); generalizable to any private interface
BindOff by default. Requires lf gateway serve --tailscale AND a passing lf gateway doctor.
AuthenticationEd25519 device signature + Supabase JWT — Tailscale identity is ignored for authn
AuthorizationSame as Scope A
Conflict policySame 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

AspectDecision
TransportSupabase REST / RPC / Realtime (postgres_changes + broadcast)
AuthenticationSupabase JWT for reads; Supabase JWT + signed envelope for trust-state mutations
AuthorizationRLS deny-by-default + SECURITY DEFINER RPCs
Conflict policyPer-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)

ClassSource of truthLocal representation
xp_totalxp.totalsPull-only cache
trust_evaluationexecution.trust_evaluationsPull-only cache
battle_resultbattles.battles (status='published')Pull-only cache
policyagents.workspace_settingsPull-only cache
budgetagents.workspace_settingsPull-only cache
kill_switchagents.workspace_settings.global_kill_switchPull-only; daemon polls every 10 s
dark_launchagents.workspace_settings.dark_launch_*Pull-only
ai_catalogai.providers / ai.modelsPull-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)

ClassWhere it livesWhy local-only
byok_keyOS keychain (via libs/utils/keychain) or envNever written to DB; documented in libs/providers/src/lib/byok-key-resolver.ts
local_battleuser runtime local-battles/*.json (encrypted)AES-256-GCM envelope; passphrase from env
scratchpad_draftDaemon process memoryEphemeral
keychain_entryOS keychainNever leaves the device
private_keyOS keychainNever leaves the device

If raw cloud sync is ever attempted on these classes, the daemon refuses with local_only_class.

Conflict-aware (bidirectional)

ClassCloud table(s)Default merge policy
agent_configagents.ai_lensersLWW per field with vector clock
agent_team_graphagents.teams, agents.team_edgesLWW per edge with vector clock
workflow_definitionlenses.workflows, lenses.versionsLWW per version with created_at tiebreak
lens_draftlenses.lenses (draft state only)LWW per field
runner_metadataexecution.runner_adapters (config JSON only)LWW per field
non_secret_preflensers.preferencesLWW per field
automation_registry_entrynone (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 with cloud_authoritative.
  • objectClassesByAuthority('local') — never enters the outbox; pushes rejected with local_only_class.
  • objectClassesByAuthority('conflict_aware') — bidirectional sync.
  • pushableObjectClasses() — equals conflict_aware. Only these are eligible for the outbox.
  • pullableObjectClasses() — equals cloud ∪ 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:

  1. Vector-clock causality wins. If A happens-before B (every component of A ≤ corresponding component of B), take B. If B happens-before A, take A. Equal clocks → take A (deterministic).
  2. 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.
  3. 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

sql
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

sql
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

sql
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

RPCPurposeCaller
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:

sql
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

FailureBehavior
Outbox push failsRetry with exponential backoff; daemon surfaces via gateway_status='degraded'. Outbox is durable; nothing is lost.
Pull failsWatermark is not advanced; next pull retries.
Nonce replayRPC returns nonce_replay; daemon discards envelope, regenerates nonce.
Clock skew detectedDaemon refuses to start (> 5 min); lf gateway doctor reports.
Conflict cannot auto-mergeConflict row written; lf gateway sync status --conflicts lists it; user resolves via CLI or web.
Local-only class push attemptedDaemon refuses with local_only_class; logged to audit chain.