Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7846,6 +7846,14 @@ describe("inbound changeset_batch guards", () => {
};
}

/** One column of one `terminal_sessions` row, as a peer would author it. */
function makeSettleChange(cid: string, dbVersion: number, seq: number, val: string): CrsqlChangeRow {
const change = makePeerChange("terminal_sessions", dbVersion, seq, val);
change.cid = cid;
change.pk = "session-1";
return change;
}

function createGuardHost(projectRoot: string, applyChanges: ReturnType<typeof vi.fn>) {
const base = createHostArgs(projectRoot, []);
return createSyncHostService({
Expand Down Expand Up @@ -7977,6 +7985,140 @@ describe("inbound changeset_batch guards", () => {
cleanup();
}
});

it("strips a phone's settle columns while applying the rest of the same batch", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const applyChanges = vi.fn((changes: CrsqlChangeRow[]) => ({ appliedCount: changes.length }));
const host = createGuardHost(projectRoot, applyChanges);
let peer: Awaited<ReturnType<typeof connectPeer>> | null = null;
try {
const port = await host.waitUntilListening();
peer = await connectPeer(port, host.getBootstrapToken(), "ios-settler");

// `settled_at` is host-authoritative. A phone on a build that predates the
// fix still writes it into its own CRR replica optimistically, and
// `terminal_sessions` replicates — so without this filter the phone's row
// merges upstream and settles a session the host *rejected*. The guard has
// to live here because a CRDT merge never reaches the caller a host-side
// check would guard.
const requestId = "batch-settle";
peer.ws.send(encodeSyncEnvelope({
type: "changeset_batch",
requestId,
payload: {
batchId: requestId,
fromDbVersion: 0,
toDbVersion: 5,
changes: [
makeSettleChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z"),
makeSettleChange("settle_override", 2, 1, "settled"),
makeSettleChange("settle_source", 3, 2, "user"),
// The snooze overlay is NOT host-authoritative — the phone owns its
// optimistic write there and it must keep replicating.
makeSettleChange("snoozed_until", 4, 3, "2026-08-11T00:00:00.000Z"),
makeSettleChange("title", 5, 4, "renamed from phone"),
],
},
}));

const ack = await waitForEnvelope(peer.envelopes, "changeset_ack", requestId);
expect((ack.payload as { ok?: boolean }).ok).toBe(true);
expect(applyChanges).toHaveBeenCalledTimes(1);
const appliedRows = applyChanges.mock.calls[0]?.[0] as CrsqlChangeRow[];
expect(appliedRows.map((row) => row.cid)).toEqual(["snoozed_until", "title"]);
} finally {
try {
peer?.ws.close();
} catch {
// ignore
}
await host.dispose();
cleanup();
}
});

it("acks a batch that was entirely settle columns without applying anything", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const applyChanges = vi.fn((changes: CrsqlChangeRow[]) => ({ appliedCount: changes.length }));
const host = createGuardHost(projectRoot, applyChanges);
let peer: Awaited<ReturnType<typeof connectPeer>> | null = null;
try {
const port = await host.waitUntilListening();
peer = await connectPeer(port, host.getBootstrapToken(), "ios-settler-only");

const requestId = "batch-settle-only";
peer.ws.send(encodeSyncEnvelope({
type: "changeset_batch",
requestId,
payload: {
batchId: requestId,
fromDbVersion: 0,
toDbVersion: 1,
changes: [makeSettleChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z")],
},
}));

// A silent drop must still ack ok, or the phone re-sends the same range
// forever: its outbound cursor only advances on an ok ack.
const ack = await waitForEnvelope(peer.envelopes, "changeset_ack", requestId);
const ackPayload = ack.payload as { ok?: boolean; appliedCount?: number };
expect(ackPayload.ok).toBe(true);
expect(ackPayload.appliedCount).toBe(0);
expect(applyChanges).not.toHaveBeenCalled();
} finally {
try {
peer?.ws.close();
} catch {
// ignore
}
await host.dispose();
cleanup();
}
});

it("keeps applying settle columns from a paired desktop peer", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const applyChanges = vi.fn((changes: CrsqlChangeRow[]) => ({ appliedCount: changes.length }));
const host = createGuardHost(projectRoot, applyChanges);
let peer: Awaited<ReturnType<typeof connectPeer>> | null = null;
try {
const port = await host.waitUntilListening();
// A desktop runs the same `sessionService` chokepoint, so its settle
// writes are host-decided too and must keep replicating.
peer = await connectPeer(port, host.getBootstrapToken(), "desktop-peer", {
platform: "macOS",
deviceType: "desktop",
});

const requestId = "batch-settle-desktop";
peer.ws.send(encodeSyncEnvelope({
type: "changeset_batch",
requestId,
payload: {
batchId: requestId,
fromDbVersion: 0,
toDbVersion: 1,
changes: [makeSettleChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z")],
},
}));

const ack = await waitForEnvelope(peer.envelopes, "changeset_ack", requestId);
const ackPayload = ack.payload as { ok?: boolean; appliedCount?: number };
expect(ackPayload.ok).toBe(true);
expect(ackPayload.appliedCount).toBe(1);
expect(applyChanges).toHaveBeenCalledTimes(1);
const appliedRows = applyChanges.mock.calls[0]?.[0] as CrsqlChangeRow[];
expect(appliedRows.map((row) => row.cid)).toEqual(["settled_at"]);
} finally {
try {
peer?.ws.close();
} catch {
// ignore
}
await host.dispose();
cleanup();
}
});
});

describe("sync host handoff over a shared listener", () => {
Expand Down
45 changes: 44 additions & 1 deletion apps/ade-cli/src/services/sync/syncHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,35 @@ const SYNC_HOST_AUTHORITATIVE_TABLES = new Set([
const isHostAuthoritativeTable = (change: CrsqlChangeRow): boolean =>
SYNC_HOST_AUTHORITATIVE_TABLES.has(change.table);

/**
* Settle columns on `terminal_sessions`. The host decides these — a settle
* arrives as a `session.settle*` remote command and is written by
* `sessionService`, which is the only place that can weigh the decision against
* live work.
*
* A phone must not author them over CRR. `terminal_sessions` replicates, so a
* phone's optimistic `settled_at` carries no host lifecycle revision and merges
* in regardless of what the host decided: the host can *reject* a settle and
* still end up with a settled row. That is a guard defeated by a merge rather
* than by a caller, and no amount of host-side checking closes it.
*
* Current iOS builds no longer write these (they use a local pending-UI overlay
* instead — see `PendingSessionSettleStates.swift`), but a paired phone on an
* older build still does, so the host enforces it rather than trusting the
* client version. The drop is silent and per-column: everything else in the
* batch, including the phone's own snooze overlay, applies normally.
*
* Scoped to phone peers on purpose. A paired *desktop* peer runs the same
* `sessionService` chokepoint, so its settle writes are host-decided too and
* must keep replicating.
*/
const HOST_AUTHORITATIVE_COLUMNS_BY_TABLE = new Map<string, ReadonlySet<string>>([
["terminal_sessions", new Set(["settled_at", "settle_override", "settle_source"])],
]);

const isHostAuthoritativeColumn = (change: CrsqlChangeRow): boolean =>
HOST_AUTHORITATIVE_COLUMNS_BY_TABLE.get(change.table)?.has(change.cid) ?? false;

const MOBILE_REPLICA_RESEED_EXCLUDED_TABLES = [
...MOBILE_CHANGESET_EXCLUDED_TABLES,
...SYNC_HOST_AUTHORITATIVE_TABLES,
Expand Down Expand Up @@ -7596,7 +7625,21 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
}
// Brain-seizure guard: never let a peer's CRR rows for host-authoritative
// tables (e.g. sync_cluster_state) win and flip brain ownership.
const filtered = changes.filter((change) => !isHostAuthoritativeTable(change));
// Settle-authority guard: never let a phone's optimistic settle column
// merge over the host's decision (older iOS builds still write them).
// The two rules are not symmetric: the table rule applies to every
// peer, the column rule only to phones.
//
// `isMobilePeer`, not `isMobileChangesetPeer`: it resolves a
// record-backed peer through its PAIRING RECORD rather than the
// `hello` metadata the peer declares about itself, so a paired phone
// cannot opt out of the guard by claiming to be a desktop.
const isPhonePeer = isMobilePeer(peer);
const filtered = changes.filter((change) => {
if (isHostAuthoritativeTable(change)) return false;
if (isPhonePeer && isHostAuthoritativeColumn(change)) return false;
return true;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
let appliedCount = 0;
if (filtered.length > 0) {
Expand Down
8 changes: 8 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */; };
B7000000000000000000002F /* PairedHostCredentialStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7000000000000000000001F /* PairedHostCredentialStateTests.swift */; };
B70000000000000000000099 /* SyncAccountConnectRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000098 /* SyncAccountConnectRecoveryTests.swift */; };
B7000000000000000000009B /* PendingSessionSettleStates.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7000000000000000000009A /* PendingSessionSettleStates.swift */; };
B7000000000000000000009D /* PendingSessionSettleStatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7000000000000000000009C /* PendingSessionSettleStatesTests.swift */; };
B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000001 /* SyncTransportSelectionTests.swift */; };
B80000000000000000000002 /* SyncConnectionRace.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80000000000000000000001 /* SyncConnectionRace.swift */; };
B80000000000000000000004 /* SyncTerminalInputQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80000000000000000000003 /* SyncTerminalInputQueue.swift */; };
Expand Down Expand Up @@ -481,6 +483,8 @@
B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncRecoveryPolicyTests.swift; path = ADETests/SyncRecoveryPolicyTests.swift; sourceTree = "<group>"; };
B7000000000000000000001F /* PairedHostCredentialStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PairedHostCredentialStateTests.swift; path = ADETests/PairedHostCredentialStateTests.swift; sourceTree = "<group>"; };
B70000000000000000000098 /* SyncAccountConnectRecoveryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncAccountConnectRecoveryTests.swift; path = ADETests/SyncAccountConnectRecoveryTests.swift; sourceTree = "<group>"; };
B7000000000000000000009A /* PendingSessionSettleStates.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PendingSessionSettleStates.swift; path = ADE/Services/PendingSessionSettleStates.swift; sourceTree = "<group>"; };
B7000000000000000000009C /* PendingSessionSettleStatesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PendingSessionSettleStatesTests.swift; path = ADETests/PendingSessionSettleStatesTests.swift; sourceTree = "<group>"; };
B90000000000000000000001 /* SyncTransportSelectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncTransportSelectionTests.swift; path = ADETests/SyncTransportSelectionTests.swift; sourceTree = "<group>"; };
B80000000000000000000001 /* SyncConnectionRace.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncConnectionRace.swift; path = ADE/Services/SyncConnectionRace.swift; sourceTree = "<group>"; };
B80000000000000000000003 /* SyncTerminalInputQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncTerminalInputQueue.swift; path = ADE/Services/SyncTerminalInputQueue.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -920,6 +924,7 @@
A90000000000000000000011 /* ProductAnalytics.swift */,
66B5024B0A05F3D9754101F1 /* SyncService.swift */,
B70000000000000000000001 /* SyncRecoveryPolicy.swift */,
B7000000000000000000009A /* PendingSessionSettleStates.swift */,
B80000000000000000000001 /* SyncConnectionRace.swift */,
B80000000000000000000003 /* SyncTerminalInputQueue.swift */,
A11700000000000000000001 /* MobileUsageQuotaStore.swift */,
Expand Down Expand Up @@ -1095,6 +1100,7 @@
B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */,
B7000000000000000000001F /* PairedHostCredentialStateTests.swift */,
B70000000000000000000098 /* SyncAccountConnectRecoveryTests.swift */,
B7000000000000000000009C /* PendingSessionSettleStatesTests.swift */,
B90000000000000000000001 /* SyncTransportSelectionTests.swift */,
AF00000000000000000000A4 /* PairingAndDpopTests.swift */,
AC1000000000000000000008 /* ClipPairingHandoffTests.swift */,
Expand Down Expand Up @@ -1431,6 +1437,7 @@
C5B00000000000000000000C /* SSHPairingViewModel.swift in Sources */,
C5B00000000000000000000D /* SSHPairingView.swift in Sources */,
B70000000000000000000002 /* SyncRecoveryPolicy.swift in Sources */,
B7000000000000000000009B /* PendingSessionSettleStates.swift in Sources */,
B80000000000000000000002 /* SyncConnectionRace.swift in Sources */,
B80000000000000000000004 /* SyncTerminalInputQueue.swift in Sources */,
A11700000000000000000002 /* MobileUsageQuotaStore.swift in Sources */,
Expand Down Expand Up @@ -1609,6 +1616,7 @@
B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */,
B7000000000000000000002F /* PairedHostCredentialStateTests.swift in Sources */,
B70000000000000000000099 /* SyncAccountConnectRecoveryTests.swift in Sources */,
B7000000000000000000009D /* PendingSessionSettleStatesTests.swift in Sources */,
B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */,
AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */,
AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */,
Expand Down
33 changes: 13 additions & 20 deletions apps/ios/ADE/Services/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2092,31 +2092,30 @@ final class DatabaseService {
notifyDidChange(touchedTables: ["terminal_sessions"])
}

/// Optimistic local write for the ADE-125 session lifecycle columns
/// (settle / settle override / snooze overlay / woke marker). The phone is a
/// controller and never owns these values — the host's remote command is the
/// source of truth and reconciles over sync — but writing locally first keeps
/// the row from flickering back for a round trip.
/// Optimistic local write for the snooze visibility overlay (`snoozed_until`,
/// `snoozed_at`) and the woke marker. The phone is a controller and never owns
/// these values — the host's remote command is the source of truth and
/// reconciles over sync — but writing locally first keeps the row from
/// flickering back for a round trip.
///
/// The settle columns (`settled_at`, `settle_override`, `settle_source`) are
/// deliberately NOT writable from here — they are host-authoritative and a
/// replicating write can defeat a host rejection by CRDT merge. See
/// `PendingSessionSettleStates.swift` for the replacement.
///
/// Each parameter is a two-level optional so "leave alone" and "clear" are
/// distinguishable: `nil` skips the column, `.some(nil)` sets it to NULL,
/// `.some(value)` writes the value.
func updateSessionLifecycle(
func updateSessionSnoozeOverlay(
sessionId: String,
settledAt: String?? = nil,
settleOverride: String?? = nil,
settleSource: String?? = nil,
snoozedUntil: String?? = nil,
snoozedAt: String?? = nil,
wokeAt: String?? = nil,
wokeReason: String?? = nil
) throws {
try withLock {
try updateSessionLifecycleLocked(
try updateSessionSnoozeOverlayLocked(
sessionId: sessionId,
settledAt: settledAt,
settleOverride: settleOverride,
settleSource: settleSource,
snoozedUntil: snoozedUntil,
snoozedAt: snoozedAt,
wokeAt: wokeAt,
Expand All @@ -2125,11 +2124,8 @@ final class DatabaseService {
}
}

private func updateSessionLifecycleLocked(
private func updateSessionSnoozeOverlayLocked(
sessionId: String,
settledAt: String?? = nil,
settleOverride: String?? = nil,
settleSource: String?? = nil,
snoozedUntil: String?? = nil,
snoozedAt: String?? = nil,
wokeAt: String?? = nil,
Expand All @@ -2148,9 +2144,6 @@ final class DatabaseService {
values.append(update)
}

assign("settled_at", settledAt)
assign("settle_override", settleOverride)
assign("settle_source", settleSource)
assign("snoozed_until", snoozedUntil)
assign("snoozed_at", snoozedAt)
assign("woke_at", wokeAt)
Expand Down
Loading
Loading