Sync Packages Across Dark Instances#5685
Draft
StachuDotNet wants to merge 14 commits into
Draft
Conversation
Make the package store event-sourced: the op logs (package_ops, branch_ops, resolutions) are the canonical, append-only source of truth, and the package / location / deprecation tables are projections folded from them. A store "grows" by applying unapplied ops and evaluating values; a schema change is safe (drop projections, re-fold the log). This is the durable, stable base that sync rides on — the append side and the fold side are cleanly split. - Op logs + projections as plain SQLite tables. `Seed.growIfNeeded` applies unapplied ops, regenerates refs, and evaluates values — and self-heals a store that has ops applied but values still NULL (evaluates any NULL rt_dval on startup) instead of leaving it stuck. Value reads are null-safe for a not-yet-evaluated value (returns None, never a NULL crash). The self-heal is silent maintenance — it never prints to stdout. - Convergence: timestamp-LWW by one canonical `origin_ts` (Lww); a synced `resolutions` overlay so a human override travels and peers converge on it; `conflicts` records every auto-resolved divergence (never silent). - Meaning-stable, content-addressed hashing (alpha-normalized) so the same definition hashes identically on every instance — the basis for convergence. - Release / version model and a legacy-DB adopt that runs schema.sql before stamping. Op playback applies branch structure before content. (LibDB, LibExecution, LibSerialization, LocalExec, schema + migrations) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The F# builtins that expose the store + sync primitives to the Dark side. Kept thin and content-addressed; each carries a real capability so untrusted `dark run` code can't reach the raw file/SQL or op-log surface. - Op-log builtins: append/read the package, branch, and resolution logs; the blob channel (content-addressed bytes, fetch-on-miss) with `syncBlobInsert` re-hashing peer bytes so a poisoned/mismatched blob is rejected. - The arbitrary-path `sqlite*` builtins are gated by a `fileReadWrite` capability (the interpreter no longer skips their capability check). - Package-manager getters by hash (values / fns / types) and the conflict list/keep/acknowledge builtins that back `dark conflicts`. (backend/src/Builtins) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package sync over HTTP, written in Dark. Local-first: sync is opt-in, and
all your branches sync wholesale — a branch is the same id+name on every
instance you connect (no per-branch private/follow controls).
- Engine (sync.dark): connect/pull/serve, per-peer resume cursors, the
bounded ops-over-HTTP wire, and receive that folds branch structure before
content. `connect` probes GET /sync/health so a dead/typo'd peer URL is
caught up front. A DB error during pull stops without advancing the cursor
(no silent divergence). Adaptive daemon poll interval, clamped + validated.
- Server (sync/server.dark): the /sync/{health,events,blobs,blob} endpoints
a peer pulls from.
- CLI: `dark sync` (+ `daemon` for background pull/serve on boot),
`dark conflicts` (review + keep-mine/keep-theirs — `show` renders each
version's actual contents, not just hashes), `dark ops` (inspect the log),
`devices`, and login/serve wording fixes. `conflicts` + `ops` are in the
compact command list so they're discoverable.
- Stdlib: the generic eventLog append/fold + sqlite helpers the engine uses.
(packages/darklang)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cross-instance convergence is the property that matters, tested at three levels plus supporting unit coverage. - `MultiInstance.Tests.fs` — in-process, true multi-store (a swappable test connection repoints LibDB at each instance). Branch sync + identity consistency, package convergence, cross-store conflict + LWW + recorded divergence, keep-mine/keep-theirs, resolution-survives-rebuild, rebase and deprecation convergence, pagination. Carries an in-process CLI driver so a test can assert on what the CLI actually renders (incl. a `dark conflicts` display regression). - `SyncScenarios.Tests.fs` — fast one-store order-independence (= convergence). - `OpsProjections.Tests.fs` — receiveOps idempotency/convergence, the origin_ts MIN-reconcile, detection, the full conflict → resolution flow. - `SyncE2E.Tests.fs` — flag-gated (DARK_E2E=1) heavy tier: real `dark` processes syncing over real HTTP — the server, pull loop, cursors, and process isolation the in-process tests can't reach. Basic sync, conflict race + keep-mine/keep-theirs, branch sync, offline-peer honesty. - Testfiles (blob channel, adaptive poll, eventlog, sqlite) + unit coverage for alpha-normalized hashing and the Release model. (backend/tests, backend/testfiles) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Stdlib.List.collect` isn't a function (never defined, not a builtin), but `cli/utils/syntaxHighlighting.dark` calls it at two sites — so every `dark view` (which highlights its output) errored with "List.collect not found". Replace each `|> List.collect f` with `|> List.map f |> List.flatten` (the same semantics). Unrelated to sync — it's a pre-existing regression on main surfaced by rebasing onto it; folded here so this branch builds green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Feriel (account …0004) is the collaborator persona. schema.sql seeded an extra 'Ocean' account (…0005) that duplicated her — removed it, and moved the four SyncE2E `login Ocean` calls to `login Feriel` so they still resolve. Example peer URLs `ocean.tailnet` → `feriel.tailnet`. Prep for real two-machine testing (Stachu + Feriel). Full suite + DARK_E2E green (9,956/0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
export-seed dropped projections but kept `trace_fn_calls`/`traces` — dev run telemetry that ballooned the seed (268 MB of a 305 MB store) and thus every shipped CLI. Strip them: seed 305 MB → 35 MB, gzip → exe far smaller. Traces are never part of a seed's canon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The config path in portable mode was the relative string "rundir/cli-config.json" — resolved against the process CWD. A released exe runs from wherever the user is (e.g. $HOME), which has no rundir/, so `login` printed "Logged in" but persisted nothing, and the next `commit` said "Not logged in". Anchor the config to the store's own directory (dirname of localDbPath) — absolute, always-present, CWD-independent, and identical to today's path for the dev CLI and installed mode. SyncE2E's now-needless rundir/ setup dropped; suite green (5/5 E2E). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A keep-mine/keep-theirs override mints a synced Resolution that converges the value everywhere — but the peer that independently recorded the same divergence kept showing "1 unreviewed conflict" (value said 100, its conflict record still pointed at the stale LWW winner). recordAndApply now marks any auto-resolved conflict at the location overridden, so applying a resolution (local OR synced-in) settles the review log on every instance. Found on real two-machine testing (pop-os + framey). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`dark conflicts` listed only hashes — you couldn't tell what actually
diverged without running `show` on each. Now each conflict previews both
values inline:
Stachu.Demo.clash (value)
yours 111 · theirs 222 → theirs active (auto:last-writer-wins)
For a value it drops the redundant "val <name> =" and shows just the body;
fns/types show the trimmed signature. See the divergence at a glance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full-screen view (apps, outliner, CLI views) launched with stdin redirected — from a script, a pipe, or a daemon — crashed with a raw InvalidOperation_ConsoleReadKeyOnFile stack dump: Console.ReadKey and Console.KeyAvailable both throw when input isn't a console. Detect redirected input in readKeyOrPaste and report Escape, which every view already treats as "exit", so it closes cleanly instead of blowing up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inline conflict preview showed the item's SIGNATURE (identical on both sides) instead of its BODY (where the two versions actually differ), so a fn/type conflict looked the same on both sides. And the non-active candidate rendered its own name as "<hash:SHORT>" — a name isn't part of an item's content-addressed hash, so the pretty-printer can't resolve it from the (superseded) binding. renderInline now shows the definition body (the part after the first " = "), collapsed to one line and truncated — so you see "Hello," vs "Hey there," at a glance. renderContent restores the real leaf name (known from the conflict's location) over the placeholder, so `show` reads cleanly for fns and types too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ergence) A synced-in 'keep theirs'/'keep mine' resolution was silently reverted on the next grow, so two peers that agreed via a resolution DIVERGED. Effective binding = fold(package_ops)[LWW] -> then apply resolutions. The full rebuild path (rebuildProjections) already re-applied the overlay after re-folding, but growIfNeeded — the incremental fold that runs when a pull brings new ops — folded them by LWW and never re-applied resolutions. So a 'keep theirs' that a peer synced in was clobbered by the very op it was meant to override the moment the next command triggered a grow: the binding reverted to the LWW loser while the deciding peer stayed on the chosen version. Repro: A and B both author name N differently; B keep-theirs -> B converges; A pulls B's resolution -> A momentarily converges, then a grow reverts A to the LWW winner. A=X, B=Y, stuck. Fix: growIfNeeded re-applies Resolutions.reapplyAll after the fold when it applied new ops, symmetric with rebuildProjections. Also mark the conflict 'overridden' in reapplyAll (mirrors recordAndApply) so the resolved divergence doesn't pop back up as unreviewed in `dark conflicts` after a grow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… the fix) 3232aa6 re-applied the overlay after growIfNeeded's fold, but the pull folds through a different path — receiveOps calls applyUnappliedOps directly and returns, so after a pull all ops are already applied and the next growIfNeeded sees appliedCount=0 and never re-applies. Net: a synced-in keep-theirs still lost to auto-LWW on the receiving peer. Add the reapply at receiveOps' own fold site (gated on foldedCount>0), so the overlay is restored immediately after the pull folds ops — including the case where the overlay's own apply was an idempotent no-op because the peer happened to already be on the chosen value, and the later fold then moved it to the LWW winner. Semantics: a manual resolution DOMINATES the auto pick, locally and on any peer it syncs to (verified target: reapplyAll is not stale-gated out here — res.at is later than the op it overrides). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jul 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ready for preliminary review, but not yet full -- I'm still tightening PR description, testing across machines, tightening code changes, etc. Close.
Summary
This adds package sync to Dark: run more than one instance — two of your own machines, or you and a
collaborator — connect them, and package edits, branch structure, and conflict resolutions flow between them over
HTTP, converging to the same state on every instance. It's opt-in and, once set up, automatic; local and offline
stay the default.
I built it on the storage we already have rather than adding a new mechanism. Darklang already stores packages as
an op log (
package_ops), with every queryable table — functions, types, values, locations — folded from thatlog as a projection. So sync is just: a peer serves its new ops, you append them, and they fold in exactly like
your own edits. No merge engine, no separate wire format — the op log is what travels.
Making that deterministic and safe needed a few supporting pieces, also here:
origin_ts) on each op, so concurrent edits resolve last-writer-wins the same way onevery instance;
working across versions;
Releaseversion coordinate, so peers refuse to sync across incompatible versions.The F# core stays small; the transport, peer config, daemon, and CLI are all
.dark.A few decisions I'd flag for review: connecting a peer is a full trust boundary for now (peers fully trusted —
see TODOs); the
origin_tsMIN-reconcile on receive is what makes LWW converge (worth a careful look); and I keptthe three logs as plain SQLite tables rather than a value type (reasoning under Internals). I've tried to be
honest below about what's a v1 tradeoff vs. actually done.
Supersedes the earlier drafts #5676 and #5684. Two things changed deliberately since those: sync is
wholesale, not per-branch — #5684's
follow/privatecontrols are gone (theprivatepromise leakedthrough the content-addressed blob channel, so rather than ship a broken guarantee, every branch syncs and
proper scoped sync is a future PR); and transport is HTTP-only — #5676's file mode (
dark sync pull <peer.db>) is dropped (a peer is a running instance, not a.dbfile). This branch is also rebased ontocurrent
mainand organized as five reviewable commits (store · builtins · Dark surface · tests · oneunrelated
dark viewfix — see below).At a glance
.dark.dark syncconnects peers and pulls their new ops over HTTP;dark sync serveserves yours;dark sync daemonruns both on a poll interval. Content-addressed ops make a re-pull ano-op;
origin_tslast-writer-wins makes concurrent edits converge regardless of arrival order.package_ops/branch_opsalready existed. Each op now carries anorigin_ts(its author's stamp, preserved across sync) for LWW convergence;package_ops's primary keybecomes
(id, branch_id)(fixing a ghost-function bug where the same op on two branches collided).Projections are unchanged — still folded from the log, still regenerable.
resolutions— a new synced log. Two instances editing the same name auto-resolve by LWW, but thedivergence is recorded, never silent; overriding it mints a
Resolutionthat syncs, so every peer convergeson your pick.
whateveris the same
whatever(same id + name) everywhere, never scoped by peer or account. Per-branch controls(
follow/private) were dropped as a half-measure; scoped sync is a proper future PR.let/lambda/match bindernames are alpha-normalized before hashing, so a re-serialize in a new op format keeps ids stable. This is how
computeFnHash/computeValueHashwork — not a separate step.packageOps/branchOps/resolutions{Read,Append}Nativeassemble records in F#(like
Traces), so serving thousands of ops is milliseconds, not per-row interpreter overhead.Releaseversion coordinate. A version stamp in the store; a peer on a different release is refused with aplain message, and the migrator carries the log forward and re-folds projections in the new shape.
rt_dvalis NULL until it's run); thestore now evaluates any such values on the next startup, and the value read is null-safe — so a not-yet-
evaluated synced value returns cleanly instead of crashing a command with an internal NULL error.
listAppendO(n²)→O(n) — every largeList.mapin Darkwas quadratic; native log reads took a 500-op read from 57s to ~1s.
lines; scenarios cover convergence, cross-store conflicts + resolution, bidirectional pull, branch
sync/merge/rebase-convergence, branch-identity consistency, and pagination. Full suite green.
UX and design
Quick start
Two machines on a tailnet, syncing in under a minute — author a value on one, watch it appear on the other:
Nothing crosses the wire but ops — no
.dbfiles, no ATTACH.The whole surface (
dark syncwith no args pulls now;dark sync helpprints this):How it works
Sync is: read a peer's new events since where you left off, append them to your own log, where they fold in
exactly like your own edits. A peer serves its committed events since a cursor (as JSON); you GET them and append
them, which inserts each op and folds it — in F#, the same playback a local edit uses.
Two properties make this converge:
origin_ts(last-writer-wins — a bind that predates thecurrent one is skipped), so concurrent edits reach the same state on every instance regardless of arrival order.
The subtle bit — the one I'd most want reviewed — is that timestamp. A received op carries its author's
origin_ts(a fresh local stamp would be wrong for replication). Because an op id hashes only content, twoinstances that make the same edit stamp it independently — so on receive I reconcile the stamp to the earliest
(MIN) across instances, giving every store one canonical
origin_tsper op. That's what makes LWW pick the samewinner everywhere.
Connecting, pulling, serving
The CLI reads like Dark — plain, honest, no jargon. Connect a peer (a
.dbpath is refused — a peer is a runninginstance), then pull:
connectprobes the peer immediately, so a dead port or typo'd URL is caught right there (Added <url> — can't reach it yet…) instead of surfacing later on a silent pull.Nin "Pulled N changes" is what actually applied — a re-pull, or a first pull of a shared base, reports 0, notthe wire size. To be reachable,
dark sync serveprints the address peers actually use:Peers are URLs only; cursors live locally (
sync_cursors_v1) and never sync, so an interrupted pull resumes whereit stopped.
Branches — wholesale + globally consistent
Branch structure is just another synced log: a peer's
branchOps(create/commit/merge/rebase) travel before itspackage ops, so an incoming package op's
branch_idalways resolves on the receiver. Every branch syncswholesale — a branch
whateveris the samewhatever(same id + name) on every instance you connect, neverscoped by which peer or account it came from, and you never have to think about it.
dark sync statusshows whoyou sync with and the branches that travel:
Per-branch controls (
follow/private) were removed. They were a half-measure — the server-sideprivatecontrol didn't actually hold (a private branch's content still leaked through the content-addressed blob
channel), so rather than ship a broken privacy promise, sync is wholesale now and proper scoped sync is a future
PR.
Concurrent rebases of the same branch converge deterministically (LWW on a synced
origin_ts), so branch basestate agrees on every instance regardless of arrival order.
Conflicts + resolutions
Two instances can bind the same name to different content at once. LWW picks a deterministic winner — but
silently dropping the other edit isn't acceptable, so as received ops fold, every such divergence is recorded
in
sync_conflicts(a local review log, never synced) with both candidates and the winner. Auto-resolution stillhappens; it's just never silent:
showrenders each side's actual source (not just its hash), so keep-mine vs keep-theirs is an informeddecision rather than a coin flip.
okaccepts the auto choice;keep-mine/keep-theirsoverride it. An override mints aResolution— a thinoverlay binding a contested location to a chosen reference, with a fresh
atstamp so it competes in the sametimestamp-LWW that orders bindings. It is not a new op (re-emitting the
SetNamewould collide on content hash).The effective binding is
fold(package_ops)under LWW, thenresolutionsapplied last. Resolutions sync as thethird log, so your "keep mine" decision propagates and every peer converges on your pick.
Automatic sync — the daemon
dark sync daemon start | stop | status | configis automatic sync as one toggle over both the pull daemon andthe serve daemon. The first
startruns a short interactive dialog; later starts are a one-line note. It'sEOF-safe: piped or no-TTY falls through to defaults without blocking.
Mode is
pull+serve/pull-only/serve-only; settings live in cli-config and are adjustable any time withdark sync daemon config <key> <value>. (dark versionshows theReleasecoordinate, so you know whether you'rerelease-compatible with a peer.)
Internals
The F# core stays small (PT/RT plus the hot loops); most of the surface is
.dark.The op logs — plain SQLite tables, read/written by native builtins
package_opsandbranch_opsalready existed as Darklang's package storage. This PR keeps them plain tables (nobespoke "event log" type) and adds
resolutionsas a third:package_ops— authored package content (add/rename/deprecate fn·type·value). Adjusted: primary key is now(id, branch_id)(an identical op on two branches used to collide on a bareidand vanish — a ghost-functionbug), and each row carries
origin_ts.branch_ops— branch structure, applied before package ops sobranch_ids resolve. Adjusted: carriesorigin_ts(rebase LWW). (Branch identity travels inside the op blob, so no side columns are needed.)resolutions— new: synced human/policy overrides, applied last so an override wins.Each is read as a
Stream<Event>after an opaqueCursor, built natively — the F# builtin assembles the recordsdirectly (like
Traces), so serving thousands of ops is milliseconds. Folding is invisible to non-F# code: opsjust play back when appended.
There's deliberately no value type wrapping these tables. I wrapped them in a general
EventLogvalue typeat first — mirrored across every serializer, typechecker, and interpreter — but a perf spike settled it:
Stdlib.Sqlitealready reads the tables into Dark at roughly native speed, so the eventual path to a Dark-managedstore is "plain tables +
Stdlib.Sqlite," and the native fold is a swappable optimization over SQL. The valuetype earned neither, so I removed it — the logs are plainly tables. (See TODOs for the eventual Dark-side fold.)
Convergence: one LWW rule, one canonical timestamp
Lww.isStaleis the single timestamp-LWW staleness predicate that the fold, conflict detection, and the resolutionoverlay all share, so the convergence rule can't drift between them. On receive,
receiveOpsreconciles each op'sorigin_tsto the MIN across instances (so independently-authored duplicates converge), folds, thendetectDivergencesrecords same-name conflicts — firing only on ops about to fold, so a re-pull raises no phantomconflicts.
Meaning-stable hashing
An op id is a content hash of a canonical serialized form, and that form must not depend on incidental
bound-variable names — two functions identical up to a
let/lambda/match binder rename are the same function andmust hash the same. So
computeFnHash/computeValueHashrename every binder to a canonical$0,$1,… (in afixed structural traversal) before serializing. This lives inside the hashing module — it is how a fn/value
hashes, not a separate pass. (Parameters are already positional
EArg index, not hashed by name.) The payoff:an op-format re-serialize keeps ids stable, so content-addressing — and therefore sync's idempotency — survives
format changes.
Changes by layer
PackageRefs.fsgains references to the sync record types (Cursor/Event/Commit/ …) that live instdlib/eventLog.dark.Seed.fs:receiveOps(append, MIN-reconcileorigin_ts, fold,detectDivergences),eventsSince/branchOpsSince(native reads),receiveBranchOps.Lww.fs: the shared staleness predicate.Conflicts.fs:detectDivergences, thesync_conflictslocal review log, record/acknowledge/currentBinding.Resolutions.fs: the synced overlay (mk/recordAndApply/resolutionsSince/receiveResolutions),domain-typed (
location: PackageLocation,choice: Reference).BranchOpPlayback.fs: rebase LWW-gate.New tables:
sync_conflicts(local),resolutions(synced).Libs/Sqlite.fs: the native read/append builtins for the three logs, plus the rawStdlib.Sqlitefloor.
Libs/Conflicts.fs: behinddark conflicts. Bundled perf:Libs/List.fslistAppendO(n²)→O(n) —two already-valid
DLists concat validly iff their ValueTypes merge (O(1)), somap/filter/reversestopbeing quadratic.
stdlib/sqlite.dark: the raw floor (exec/query+ injection-safe…Pvariants, typedValue)plus a thin convenience layer (
column/queryOne(P)/scalarInt(P)/textField/intField) socallers ask for a column/scalar/field.
stdlib/eventLog.dark: the typed read/append API over the three tables.sync.dark:wireSince,pull, per-(peer,kind)cursors, the blob channel, the poll daemon;sync/server.dark:/sync/health+/sync/events.cli/sync.dark: the wholedark syncsurface;cli/conflicts.dark:dark conflicts;cli/ops.dark:dark ops(inspect the op log live from SQLite).One unrelated fix (isolated in its own commit)
Rebasing onto current
mainsurfaced a pre-existing regression there:cli/utils/syntaxHighlighting.darkcallsStdlib.List.collect, which doesn't exist — sodark viewerrors on plainmainwith "List.collect notfound". This PR's last commit replaces each
|> List.collect fwith|> List.map f |> List.flatten(samesemantics) to unbreak
view. It's kept as a separate, clearly-labeled commit since it's not sync — happy tosplit it into its own PR if you'd rather land it independently.
Tests
Rebased onto current
main, the full suite withDARK_E2E=1is green in one run — 9,956 passed / 0 failed /0 errored (the self-heal above is what lets the real-HTTP E2E tier seed from an unevaluated store without
tripping).
MultiInstance.Tests.fs— a fresh-instance harness (freshInstance/seededInstance, a fast copy of aseeded baseline) + a test-only swappable connection (
LibDB.Sqlite.useStoreForTesting) that repoints all ofLibDB at an instance. TRUE multi-store scenarios: branch sync, branch create+merge, package convergence,
cross-store conflict + LWW + recorded divergence, bidirectional pull, pagination, resolution convergence,
rebase convergence (order-independent), branch-identity consistency, deprecation convergence, and a
resolution surviving a projection rebuild. It also carries an in-process CLI
driver (
runCli, the sameexecuteCliCommandseamCliTraces.Tests.fsuses) so a test can build a realstore state and assert on what the CLI actually renders — including a regression for the
dark conflictsdisplay crash (a genuine recorded conflict must render, not throw).
SyncScenarios.Tests.fs— fast one-store scenarios (order-independence = convergence).OpsProjections.Tests.fs—receiveOpsidempotency/convergence, theorigin_tsMIN-reconcile, detection,the full conflict → resolution flow.
SyncE2E.Tests.fs— a flag-gated heavy tier (OFF by default;DARK_E2E=1 ./scripts/run-backend-tests --filter-test-list "SyncE2E"). Spins up realdarkprocesses, each its own rundir +data.db, and syncsover real HTTP — the layer the in-process tests can't reach (
/sync/eventsserver, the real pull loop +cursors, process isolation). Scenarios: basic sync + convergence + idempotent re-pull, the conflict race →
keep-mine → converge, and offline-peer honesty. When the flag is absent it's a single skipped test, so a normal
run neither builds the exe nor pays the cost.
cli/sync-per-branch.dark(the blob channel + adaptive poll interval),stdlib/eventlog.dark,stdlib/sqlite.dark.separate stores, real HTTP, cold first-sync with no cursor priming. Cold value sync → converge → usable;
concurrent-edit conflict →
dark conflicts→ keep-mine → converge; the background daemon auto-pulling a peer'snew value within seconds; a branch keeping the same uuid + name on both instances; a function authored on one
instance pulled and executed on the other; and repeated re-pulls staying idempotent (op count + hashes
stable). The core "two people author and sync" loop works end to end.
TODOs (not in this PR)
auto-evaluate,
dark sync servebinds all interfaces unauthenticated. Accepted for a local-first v1 (apeer is your own instance or a trusted collaborator on a private tailnet), marked
CLEANUP(sync-security).Already hardened in this PR: the arbitrary-path
sqlite*builtins are capability-gated (Needs.fileReadWrite— the interpreter no longer skips their check), and
syncBlobInsertre-hashes peer bytes so a poisoned/mismatched blob is rejected. Remaining: verify op ids against content, reject future-dated
origin_ts, gatehttpGetUnsafeBytesaway from synced-value evaluation (TODO(sync-ssrf)), bind localhost + expose viadark devices serve, authenticated serving.origin_ts,LWW-gated via
base_ts/base_op; merge/archive are monotonic set-once). Residual: a same-millisecond rebasetie-breaks by op-id but no
SyncConflictis recorded for structural divergence yet (name conflicts are).tailnet; a future per-branch scoped sync (server pre-filtering by what a puller wants) would cut the transfer
when it returns.
Stdlib.Sqlite(spike: ~10.8k ops in ~2.26s). The fold stays native F# only because per-row Dark access isstill ~10ms/
Dict.get(folding the seed in-interpreter is ~9 min). When that interpreter cost drops the foldmoves to Dark over the same tables — gated on interpreter perf, not architecture.
receiveOps/eventsSincedistinct commit/event record types (two identical tuples today);consolidate the
Sqlite.fsstream/record boilerplate + nits (shared conflict-status constants,short()dupeshashToShort, a staleSqlite.fsheader, double-deserialize inreceiveOps); finishchosen→choiceon theDark/wire mirrors; align the daemon config namespace (
sync.daemon.*vsapps.sync.*) and the table suffixes.This is aimed squarely at a local-first v1 — a peer is your own instance, or a trusted collaborator on a
private tailnet. The trust model (first TODO) is the one thing I'd want hardened before this faces untrusted
peers. Everything above is working and tested; I've flagged the residual edges honestly rather than hide them,
and the biggest ones (structural-conflict recording, proper per-branch scoped sync) are noted as follow-ups
that don't block the local-first case.