Make a change's Lamport clock a plain integer (protocol fix, breaking) - #229
Merged
Conversation
BREAKING: `Change.lamport` changes from `LamportTimestamp { time, author }`
to a plain `number` (the logical clock value). The author tiebreak for LWW
now comes from `Change.authorDID` (where it already lived), and the
materialized `PropertyTimestamp` gains an `author: DID` field.
This fixes a spec↔implementation divergence found while scoping the Swift
sync client: the protocol spec (02-data-model §5: `lamport: number`), the
conformance vectors, and the Python/Swift reference kernels all hash lamport
as a NUMBER, but the runtime hashed it as the object `{author,time}` — so real
changes (`cid:blake3:1555ea15…`) did not match the vectors (`…76fdfa20…`) and a
spec-conformant re-implementation (e.g. XNetKit) would be rejected by the hub.
Aligning the runtime to the spec makes the conformance corpus finally
represent reality and unblocks cross-language interop. No backwards-compat /
migration needed (not live yet).
The clock (`LamportTimestamp`/`LamportClock` in clock.ts) keeps its internal
(time, author) pair; only the value placed on the change and hashed is now a
number. Change ordering uses (lamport, wallTime, authorDID).
Touches sync (change/chain/integrity/yjs-change/serializers v1-v3), data
(NodeStore LWW + PropertyTimestamp + adapters), hub (node-relay deserialize),
history, runtime, react, data-bridge, devtools, cli, and apps/electron IPC.
88/88 typecheck tasks pass; conformance 24/24; Swift kernel 18/18; XNetKit
14/14; all per-package test suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
|
Preview removed for PR #229. |
crs48
added a commit
that referenced
this pull request
Jun 22, 2026
## What & why Native Swift can now **sync with a running XNet hub**, and it's proven **end-to-end against the real TypeScript hub** — the first true cross-language round-trip. This was unblocked by the integer-lamport protocol fix ([#229](#229)). `xnet-sync-demo`, run live against `node packages/hub/dist/cli.js --no-auth`: ``` → connected to ws://localhost:31999 as did:key:z6Mkv1o2… → created node IDF8vLzk-1v5Z75daBfvX (1 signed change) → published to room 'swift-interop-…' — the hub verifies hash + Ed25519 signature ← hub returned 1 change(s) for the room ← reader verified + applied 1 change(s) ✅ ROUND-TRIP OK reader sees: title=Sync from native Swift status=todo ``` A Swift-signed, integer-lamport change is **verified (hash + Ed25519) and stored by the TypeScript hub**, then caught up by a *second* Swift client (different identity) and materialized. ## What's added (XNetKit) - **`HubConnection`** — an L2 replication client over `URLSessionWebSocketTask`: version handshake, room subscribe, `node-change` publish, and `node-sync-request`/`-response` catch-up ([`03-replication.md`](docs/specs/protocol/03-replication.md)). - **`WireCodec`** — maps a Swift `Change` to/from the hub's `SerializedNodeChange` wire shape (flat `lamportTime`/`lamportAuthor`, base64 `signatureB64`). - **`NodeStore.onLocalChange`** hook (publishes local writes) + **`JSONValue.toFoundation()`** serializer. - **`xnet-sync-demo`** executable + a headless **`WireCodecTests`** JSON round-trip. ```swift let conn = HubConnection(url: URL(string: "wss://hub.xnet.app")!, did: identity.did) try await conn.connect() store.onLocalChange = { change in Task { try await conn.publish(change, room: docId) } } for change in try await conn.syncRequest(room: docId, sinceLamport: 0) { store.apply(change) } ``` ## Verification - `swift test` → **16 pass** (kernel/store/live-query/canonical + wire-codec round-trip). - **Live E2E**: `swift run xnet-sync-demo ws://localhost:31999` against an anonymous reference hub → `✅ ROUND-TRIP OK`. - The Swift package is top-level (`swift/`, outside the pnpm workspace) so it doesn't touch TS CI; changelog fragment included. ## Scope `HubConnection` does publish + catch-up. Still pending (tracked in the exploration): a long-lived streaming subscription for real-time inbound relays + awareness/presence, a SwiftUI sample app, persistence (GRDB), and the web-app-on-the-same-hub LWW convergence test. The live E2E requires a running hub, so (like the conformance kernels) it's a documented manual check, not a CI job. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48
added a commit
that referenced
this pull request
Jun 22, 2026
## What & why Completes the live-sync story for XNetKit: a native Swift client can now **stream relayed changes in real time**, not just publish + catch up on demand. Built on [#231](#231) (publish + catch-up) and unblocked by [#229](#229) (integer lamport). Proven **end-to-end against the reference TypeScript hub** (`xnet-sync-demo`): ``` → writer connected … created node … and published its signed change ✅ CATCH-UP OK — reader sees: title=Sync from native Swift ✅ STREAMING OK — reader received a live relayed update: title=Updated live from Swift ``` A second native-Swift client (different identity) catches up via `node-sync-request`, then **receives a hub-relayed update the moment the writer publishes it** — full bidirectional, real-time native-Swift ↔ TS-hub sync. ## What's added - **`HubConnection.subscribe(room:)`** — sends `{type:'subscribe', topics:['xnet-doc-<room>']}`. - **`HubConnection.startStreaming()`** + **`onRemoteChange`** — a background read loop that decodes relayed `{type:'publish', data:{type:'node-change', change}}` frames and delivers each (verified) change to the handler in real time. - `xnet-sync-demo` extended to demonstrate catch-up **and** a live streamed update. ```swift conn.onRemoteChange = { change in store.apply(change) } // verifies before applying try await conn.subscribe(room: docId) conn.startStreaming() ``` The control phase (handshake, `syncRequest`) reads directly; the streaming phase uses the background loop. `publish` is safe during streaming; `syncRequest` should precede it on a given connection (documented). ## Verification - `swift test` → **16 pass**. - **Live E2E**: `swift run xnet-sync-demo ws://localhost:32001` against an anonymous reference hub → `✅ CATCH-UP OK` + `✅ STREAMING OK`. - Top-level `swift/` package (outside the pnpm workspace) — doesn't touch TS CI; changelog fragment included. The live E2E needs a running hub, so (like the conformance kernels) it's a documented manual check. ## Scope Still pending (tracked in the exploration): awareness/presence, the Yjs document-body codec, a SwiftUI sample app, persistence (GRDB), and the unified send+receive router for `syncRequest` during an active stream. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
What & why
While scoping a live Swift↔hub sync client for XNetKit, I found a real spec↔implementation divergence: the runtime hashed a change's
lamportfield as the object{ time, author }, but the protocol spec (02-data-model.md§5:lamport: number), the conformance golden vectors, and the Python/Swift reference kernels (and XNetKit) all hash it as a plain integer.Empirically, the same change hashed two different ways:
cid:blake3:1555ea15…cid:blake3:…76fdfa20…So real runtime changes did not match the conformance corpus, and a spec-conformant re-implementation (XNetKit) would be rejected by the hub with
INVALID_HASH. The conformance corpus — the centerpiece of the "portable protocol" — didn't actually represent the protocol.Per direction (no backwards-compat needed, not live), this aligns the runtime to the spec:
lamportis an integer; it never made sense as an object since the author already travels inauthorDID.The change (breaking)
Change.lamport:LamportTimestamp { time, author }→number.Change.authorDID. Change ordering is(lamport, wallTime, authorDID).PropertyTimestampgains anauthor: DIDfield (was relying onlamport.author).LamportTimestamp/LamportClockinclock.ts) keeps its internal(time, author)pair; only the value placed on a change and hashed is now a number.Swept across sync (change/chain/integrity/yjs-change/serializers v1–v3), data (NodeStore LWW,
PropertyTimestamp, adapters), hub (node-relay deserialize), history, runtime, react, data-bridge, devtools, cli, and the Electron IPC layer. 56 files.Verification
pnpm turbo run typecheck→ 88/88 tasks pass, zero errors.This unblocks cross-language interop: a change created by the TypeScript runtime now hashes identically to one from the Python/Swift kernels and XNetKit.
🤖 Generated with Claude Code