Skip to content

Commit aad1468

Browse files
Sardor-Mclaude
andcommitted
chore(cli): release v0.2.0 — Tier 5 cross-device sync
Bumps lumen-kb from 0.1.4 → 0.2.0 (minor — additive sync feature, no breaking changes). Updates the Commander version string baked into the CLI binary. Adds tests/sync-e2e.test.ts: a 4-test suite that exercises the full Tier 5 loop end-to-end. Two simulated `LUMEN_DIR` instances connected by an in-memory mock relay verify that mutations on device A become observable on device B after one push → pull → apply cycle, that the relay sees only opaque ciphertext (a wrong key cannot decrypt the envelopes), and that re-running a sync cycle is idempotent (no duplicate concepts/feedback on B). Suite total now: 866 passing, 2 skipped, 11 todo (was 740 in 0.1.4). Plus 25 relay tests in apps/relay/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e5eff1 commit aad1468

4 files changed

Lines changed: 334 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,54 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.2.0] - 2026-05-05
6+
7+
### Added — End-to-end-encrypted cross-device sync (Tier 5)
8+
9+
Cross-device sync built as five additive sub-tiers. Local-first remains the default — the journal records every concept-touching mutation regardless of whether sync is enabled, and every payload is sealed with X25519 + XChaCha20-Poly1305 before it ever leaves the device. The relay sees only opaque ciphertext and never holds the master key.
10+
11+
- **Tier 5a — Sync journal foundation** (schema v15). Two new tables: `sync_state` (singleton, `CHECK id = 1`) and `sync_journal` (append-only log keyed by UUIDv7-shaped `sync_id`). Five mutator triggers (`upsertConcept`, `recordFeedback`, `updateCompiledTruth`, `retireConcept`, `captureTrajectory`) atomically append journal rows alongside their entity writes inside the same `db.transaction()` — a crash mid-write rolls both back. Per-process monotonic counter on `sync_id` guarantees within-millisecond sortability for cursor pagination.
12+
- **Tier 5b — Encryption envelope** (`apps/cli/src/sync/crypto.ts`). Pure crypto module implementing the sealed-box scheme from `SYNC-PROTOCOL.md` §3: fresh ephemeral X25519 keypair per envelope, XChaCha20-Poly1305 with a 24-byte random nonce, recipient pubkey derived deterministically from the master key. Domain-separated derivations: `deriveUserHash` (relay routing), `deriveScopeRoutingTag` (per-scope filter via HMAC), `fingerprintMasterKey` (cross-device sanity check). Pure-TS deps (`@noble/ciphers`, `@noble/curves`); no native bindings.
13+
- **Tier 5c — Relay HTTP client + push/pull driver + `lumen sync` CLI**. Three endpoints (POST/GET/DELETE journal, all keyed by `userHash`) with retry policy: max 5 attempts with backoff `[1s, 2s, 4s, 8s]`, `Retry-After` honored on 429, no sleep on the last attempt. `runPush` / `runPull` / `runSync` orchestrator with per-process consecutive-failure counter that opens a circuit-breaker after 5 failures (cleared via `lumen sync reset-error`). Keyring backends: macOS (`security` shell-out, key fed on stdin via `-w` no-arg form so it never appears in `ps`), Linux (`secret-tool`), file fallback (mode 0600), in-memory (tests). New CLI subcommands: `init` / `enable` / `disable` / `push` / `pull` / `run` / `status` / `reset-error` / `show-key --reveal` / `import-key <base64>` / `forget-key`.
14+
- **Tier 5d — Reference Cloudflare Worker relay** (`apps/relay/`). Zero-knowledge journal storage backed by D1 + per-`user_hash` rate limiting via KV. Stores opaque envelopes; never holds the master key. Endpoints documented in `apps/relay/README.md`. Comes with a 25-test vitest suite via `@cloudflare/vitest-pool-workers`.
15+
- **Tier 5e — Apply rules + LWW conflict resolution** (schema v16). `applyPending(opts?)` walks `pulled_at IS NOT NULL AND applied_at IS NULL` rows and dispatches to per-op handlers (`applyConceptCreate`, `applyTrajectory`, `applyFeedback`, `applyTruthUpdate`, `applyRetire`). Each handler writes direct SQL that bypasses the journaling mutators (so applied entries don't re-journal and bounce back to the relay). Per-entry transactional boundary — if apply throws, `markApplied` rolls back so the next call retries. Last-write-wins on `truth_update` with strict `>` comparison: ties (equal `updated_at`) keep the local truth and skip the audit row; real losses (strictly older incoming) land in `concept_truth_history` with `superseded_by` set. New table: `concept_truth_history`. New CLI subcommand: `lumen sync apply` (also baked into `lumen sync run` after push → pull).
16+
17+
### Added — Tests
18+
19+
- **132 new sync tests** across the five sub-tiers:
20+
- `sync-journal.test.ts` (21) — schema v15, state singleton, journal CRUD, write-path triggers
21+
- `sync-crypto.test.ts` (23) — master key, derivations, encrypt/decrypt round-trip + tamper + version + size
22+
- `sync-keyring.test.ts` (14) — memory + file backend round-trip + mode 0600 + env-var selection
23+
- `sync-relay-client.test.ts` (15) — POST/GET/DELETE shape, retry policy, `Retry-After`, type guard
24+
- `sync-driver.test.ts` (22) — push/pull/sync round-trip, idempotency, scope tags, circuit-breaker, terminal-page cursor
25+
- `sync-apply.test.ts` (28) — per-op handlers, LWW won/lost/tie, idempotency, orchestrator
26+
- `sync-e2e.test.ts` (4) — full cross-device flow (A → relay → B) verifying ciphertext opacity + state convergence
27+
- 5 additions in pre-existing tests for write-path trigger behavior
28+
- **25 relay tests** in `apps/relay/test/` via `@cloudflare/vitest-pool-workers`.
29+
- Suite total: **866 passing**, 2 skipped, 11 todo (was 740 in 0.1.4).
30+
31+
### Changed
32+
33+
- `lumen sync show-key --reveal` writes the base64 master key via `process.stdout.write` (no log prefix) so it pipes/copies cleanly.
34+
- `RelayError` is a branded type rather than a class (project-wide no-classes rule); use `isRelayError(err)` instead of `instanceof`.
35+
- `lumen sync run` runs **push → pull → apply** in one cycle by default. Apply doesn't touch the network, so apply failures don't trip the circuit-breaker.
36+
37+
### Schema migrations
38+
39+
- **v15**`sync_state` singleton + append-only `sync_journal` (CHECK on `op` enum, indexes on `pushed_at` / `op` / `(scope_kind, scope_key)` / `applied_at`).
40+
- **v16**`concept_truth_history` (last-write-wins audit; nullable `truth` since the displaced row may have been null) + partial UNIQUE INDEX on `concept_feedback(sync_id) WHERE sync_id IS NOT NULL` for apply idempotency.
41+
42+
Both purely additive; existing tables and prior tests untouched.
43+
44+
### Dependencies
45+
46+
- `@noble/ciphers ^1.0.0`, `@noble/curves ^1.6.0` — pure-TS, audited (Paul Miller / Noble suite), ~10 KB each, no native bindings.
47+
48+
### What's NOT in this release
49+
50+
- Multi-device key share UI — QR / BIP39 phrase / age file. Tier 6, deferred. Cross-device onboarding currently means `lumen sync show-key --reveal` on device A → `lumen sync import-key <base64>` on device B.
51+
- Background sync scheduler. Tier 6, deferred. Push/pull is currently manual via CLI.
52+
553
## [0.1.4] - 2026-04-23
654

755
### Added

apps/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lumen-kb",
3-
"version": "0.1.4",
3+
"version": "0.2.0",
44
"description": "Local-first knowledge compiler — ingest articles, papers, PDFs, YouTube into a knowledge graph with hybrid search and MCP server",
55
"type": "module",
66
"bin": {

apps/cli/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ if (process.argv.includes('--mcp')) {
6262
.description(
6363
'Intelligent knowledge compiler — ingest, chunk, search, and compile any reading into a structured knowledge graph',
6464
)
65-
.version('0.1.0');
65+
.version('0.2.0');
6666

6767
registerInit(program);
6868
registerAdd(program);

apps/cli/tests/sync-e2e.test.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
/**
2+
* Tier 5 end-to-end — exercises the full sync loop across two simulated
3+
* `LUMEN_DIR` instances connected by an in-memory mock relay. Touches every
4+
* sub-tier:
5+
*
6+
* 5a — sync_journal write-path triggers (upsertConcept, recordFeedback,
7+
* updateCompiledTruth, retireConcept all append journal rows)
8+
* 5b — encrypt/decrypt envelope round-trip
9+
* 5c — relay client push/pull + cursor pagination
10+
* 5d — exercised indirectly via the mock relay's ?since semantics
11+
* 5e — apply pass translating pulled rows into local store mutations
12+
*
13+
* Verifies that mutations on device A become observable on device B after
14+
* a single push → pull → apply cycle, with no plaintext touching the relay.
15+
*/
16+
17+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18+
import { mkdtempSync, rmSync } from 'node:fs';
19+
import { join } from 'node:path';
20+
import { tmpdir } from 'node:os';
21+
import { resetDataDir } from '../src/utils/paths.js';
22+
import { getDb, resetDb } from '../src/store/database.js';
23+
import {
24+
setMasterKey,
25+
deleteMasterKey,
26+
setKeyringBackend,
27+
setEnabled,
28+
setRelayConfig,
29+
runPush,
30+
runPull,
31+
runApply,
32+
resetCircuitBreakerForTests,
33+
generateMasterKey,
34+
deriveUserHash,
35+
fingerprintMasterKey,
36+
decryptEnvelope,
37+
type FetchLike,
38+
type PushBatch,
39+
type PullEntry,
40+
type EncryptionEnvelope,
41+
} from '../src/sync/index.js';
42+
import {
43+
upsertConcept,
44+
getConcept,
45+
updateCompiledTruth,
46+
retireConcept,
47+
} from '../src/store/concepts.js';
48+
import { recordFeedback } from '../src/store/feedback.js';
49+
50+
let dirA: string;
51+
let dirB: string;
52+
let masterKey: Buffer;
53+
54+
/** Yield long enough that consecutive ISO timestamps differ — otherwise
55+
* same-ms writes tie on `updated_at` and the LWW path returns `tie`. */
56+
function tick(): Promise<void> {
57+
return new Promise((resolve) => setTimeout(resolve, 5));
58+
}
59+
60+
beforeEach(() => {
61+
dirA = mkdtempSync(join(tmpdir(), 'lumen-e2e-A-'));
62+
dirB = mkdtempSync(join(tmpdir(), 'lumen-e2e-B-'));
63+
masterKey = generateMasterKey();
64+
process.env.LUMEN_RELAY_NO_BACKOFF = '1';
65+
});
66+
67+
afterEach(() => {
68+
deleteMasterKey();
69+
setKeyringBackend(null);
70+
resetDb();
71+
resetDataDir();
72+
delete process.env.LUMEN_DIR;
73+
delete process.env.LUMEN_RELAY_NO_BACKOFF;
74+
rmSync(dirA, { recursive: true, force: true });
75+
rmSync(dirB, { recursive: true, force: true });
76+
});
77+
78+
/**
79+
* Switch the test process between the two simulated devices. Each switch
80+
* tears down the prepared-statement cache and the singleton DB handle so
81+
* the next `getDb()` opens against the new LUMEN_DIR. The master key is
82+
* shared (simulating a successful Tier 6 key-share onboarding).
83+
*/
84+
function useDevice(dir: string): void {
85+
resetDb();
86+
resetDataDir();
87+
process.env.LUMEN_DIR = dir;
88+
setKeyringBackend('memory');
89+
setMasterKey(masterKey);
90+
getDb();
91+
setRelayConfig({
92+
user_hash: deriveUserHash(masterKey),
93+
relay_url: 'https://relay.test',
94+
encryption_key_fingerprint: fingerprintMasterKey(masterKey),
95+
});
96+
setEnabled(true);
97+
resetCircuitBreakerForTests();
98+
}
99+
100+
/** In-memory relay shared across both devices. Closes over `entries`. */
101+
function makeMockRelay(): { fetch: FetchLike; entries: PullEntry[] } {
102+
const entries: PullEntry[] = [];
103+
const fetchImpl: FetchLike = async (url, init) => {
104+
const method = init?.method ?? 'GET';
105+
if (method === 'POST') {
106+
const batch = JSON.parse(String(init?.body)) as PushBatch;
107+
for (const e of batch.entries) {
108+
entries.push({
109+
sync_id: e.sync_id,
110+
envelope: e.envelope,
111+
scope_routing_tag: e.scope_routing_tag,
112+
received_at: new Date().toISOString(),
113+
});
114+
}
115+
return new Response(JSON.stringify({ accepted: batch.entries.length, rejected: [] }), {
116+
status: 200,
117+
});
118+
}
119+
/** GET — honour the `since` query so cursor pagination behaves like Tier 5d. */
120+
const u = new URL(url);
121+
const since = u.searchParams.get('since') ?? '';
122+
const filtered = since ? entries.filter((e) => e.sync_id > since) : [...entries];
123+
return new Response(JSON.stringify({ entries: filtered, next_cursor: null }), {
124+
status: 200,
125+
});
126+
};
127+
return { fetch: fetchImpl, entries };
128+
}
129+
130+
describe('Tier 5 end-to-end (A → relay → B)', () => {
131+
it('round-trips concept_create + feedback + truth_update from A to B', async () => {
132+
const relay = makeMockRelay();
133+
134+
/** ─── Device A: mutate locally, push to relay ─── */
135+
useDevice(dirA);
136+
const now = new Date().toISOString();
137+
upsertConcept({
138+
slug: 'attention',
139+
name: 'Attention',
140+
summary: 'self-attention mechanism',
141+
compiled_truth: null,
142+
article: null,
143+
created_at: now,
144+
updated_at: now,
145+
mention_count: 1,
146+
scope_kind: 'personal',
147+
scope_key: 'me',
148+
});
149+
await tick();
150+
recordFeedback({ slug: 'attention', delta: 1, reason: 'cited in 3 papers' });
151+
await tick();
152+
updateCompiledTruth('attention', 'Self-attention scales O(n^2) in sequence length.');
153+
154+
const pushResult = await runPush({ fetchImpl: relay.fetch });
155+
expect(pushResult.errors).toEqual([]);
156+
expect(pushResult.pushed).toBe(3);
157+
expect(relay.entries).toHaveLength(3);
158+
159+
/** All envelopes on the wire must be opaque to anyone without Kx. */
160+
for (const e of relay.entries) {
161+
expect(e.envelope.v).toBe(1);
162+
expect(e.envelope.c.length).toBeGreaterThan(0);
163+
/** Plaintext is JSON; reject the trivial "looks like JSON" leak check. */
164+
const raw = Buffer.from(e.envelope.c, 'base64').toString('utf-8');
165+
expect(raw.startsWith('{')).toBe(false);
166+
}
167+
168+
/** Spot-check: round-trip one envelope with the master key reproduces the original op. */
169+
const decoded = JSON.parse(decryptEnvelope(relay.entries[0].envelope, masterKey)) as {
170+
op: string;
171+
entity_id: string;
172+
};
173+
expect(decoded.op).toBe('concept_create');
174+
expect(decoded.entity_id).toBe('attention');
175+
176+
/** ─── Device B: pull, apply, verify state matches ─── */
177+
useDevice(dirB);
178+
expect(getConcept('attention')).toBeNull();
179+
180+
const pullResult = await runPull({ fetchImpl: relay.fetch });
181+
expect(pullResult.errors).toEqual([]);
182+
expect(pullResult.pulled).toBe(3);
183+
184+
const applyResult = runApply();
185+
expect(applyResult.applied).toBe(3);
186+
expect(applyResult.apply_failed).toBe(0);
187+
188+
const conceptOnB = getConcept('attention');
189+
expect(conceptOnB).not.toBeNull();
190+
expect(conceptOnB?.name).toBe('Attention');
191+
expect(conceptOnB?.compiled_truth).toBe('Self-attention scales O(n^2) in sequence length.');
192+
expect(conceptOnB?.score).toBe(1);
193+
});
194+
195+
it('round-trips a retire from A to B (retired_at observable on B)', async () => {
196+
const relay = makeMockRelay();
197+
198+
useDevice(dirA);
199+
const now = new Date().toISOString();
200+
upsertConcept({
201+
slug: 'deprecated-pattern',
202+
name: 'Deprecated Pattern',
203+
summary: null,
204+
compiled_truth: null,
205+
article: null,
206+
created_at: now,
207+
updated_at: now,
208+
mention_count: 1,
209+
scope_kind: 'personal',
210+
scope_key: 'me',
211+
});
212+
retireConcept('deprecated-pattern', 'superseded by new pattern');
213+
214+
const pushResult = await runPush({ fetchImpl: relay.fetch });
215+
expect(pushResult.pushed).toBe(2);
216+
217+
useDevice(dirB);
218+
await runPull({ fetchImpl: relay.fetch });
219+
const applyResult = runApply();
220+
expect(applyResult.applied).toBe(2);
221+
222+
const conceptOnB = getConcept('deprecated-pattern');
223+
expect(conceptOnB).not.toBeNull();
224+
expect(conceptOnB?.retired_at).not.toBeNull();
225+
expect(conceptOnB?.retire_reason).toBe('superseded by new pattern');
226+
});
227+
228+
it('relay sees only opaque ciphertext — wrong key cannot decrypt', async () => {
229+
const relay = makeMockRelay();
230+
useDevice(dirA);
231+
const now = new Date().toISOString();
232+
upsertConcept({
233+
slug: 'private',
234+
name: 'Private',
235+
summary: 'sensitive content',
236+
compiled_truth: null,
237+
article: null,
238+
created_at: now,
239+
updated_at: now,
240+
mention_count: 1,
241+
scope_kind: 'personal',
242+
scope_key: 'me',
243+
});
244+
await runPush({ fetchImpl: relay.fetch });
245+
expect(relay.entries.length).toBeGreaterThan(0);
246+
247+
const wrongKey = generateMasterKey();
248+
expect(() => decryptEnvelope(relay.entries[0].envelope, wrongKey)).toThrow();
249+
});
250+
251+
it('pull is idempotent — re-running a sync cycle does not duplicate state on B', async () => {
252+
const relay = makeMockRelay();
253+
254+
useDevice(dirA);
255+
const now = new Date().toISOString();
256+
upsertConcept({
257+
slug: 'dedup-test',
258+
name: 'Dedup',
259+
summary: null,
260+
compiled_truth: null,
261+
article: null,
262+
created_at: now,
263+
updated_at: now,
264+
mention_count: 1,
265+
scope_kind: 'personal',
266+
scope_key: 'me',
267+
});
268+
recordFeedback({ slug: 'dedup-test', delta: 1, reason: 'first' });
269+
await runPush({ fetchImpl: relay.fetch });
270+
271+
useDevice(dirB);
272+
await runPull({ fetchImpl: relay.fetch });
273+
runApply();
274+
expect(getConcept('dedup-test')?.score).toBe(1);
275+
276+
/** Re-pull (no new entries on the relay). insertPulled is idempotent
277+
* on sync_id, applyPending is idempotent on applied_at IS NOT NULL. */
278+
const secondPull = await runPull({ fetchImpl: relay.fetch });
279+
expect(secondPull.pulled).toBe(0);
280+
const secondApply = runApply();
281+
expect(secondApply.applied).toBe(0);
282+
expect(getConcept('dedup-test')?.score).toBe(1);
283+
});
284+
});

0 commit comments

Comments
 (0)