crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 40abf3b3ae1abf5d457deeaaeab188201f6c5ac5
parent c1c6032b340e94db5f0b5b5803f68fba7373ca43
Author: Michael Camilleri <[email protected]>
Date:   Sun, 12 Jul 2026 00:03:28 +0900

Reject out-of-grid remote coordinates before persistence

A collaborator could write a Moves or Journal entry whose row or column
lay outside Int16 — decoding accepted any integer, and the cell-cache
replay then converted it with a trapping Int16 initialiser. Because the
poisoned record persists and replay runs during sync handling on every
fetch, a single bad value put the app into a crash loop that survived
relaunch, with no opportunity to remove the offending game.

This commit drops such entries at the codecs: MovesCodec and
JournalCodec now discard coordinates that are negative or not
representable as Int16, which covers the CloudKit ingress, the realtime
merge, and blobs already at rest from before the fix. Sinks that know
the game's dimensions also skip representable-but-out-of-grid values so
they never materialise as Core Data rows: live edits are validated
before they can create a Moves row, with rejections noted in the event
log, and the replay cell cache, remote journal cache, peer-change ledger
and archive materialisation apply the same bound. A peer's published
selection outside the grid is treated as no selection. Games without
recorded dimensions fall back to the representability check alone, so
legacy rows and archives keep decoding.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Persistence/GameStore.swift | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Persistence/Journal.swift | 11++++++++---
MCrossmate/Sync/Archive.swift | 3+++
MCrossmate/Sync/Moves.swift | 23+++++++++++++++++++++++
MCrossmate/Sync/RecordApplier.swift | 23++++++++++++++++++++++-
ATests/Unit/Sync/RemoteCoordinateValidationTests.swift | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 349 insertions(+), 5 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 082B9BAADE3AFA54EFE30E19 /* PuzzleModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DF04E70017065DFA95B396 /* PuzzleModifiers.swift */; }; 085B70680087464B8A7BA3EE /* GridSilhouetteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */; }; 0A7AEB93A473AFCCD9217F49 /* PuzzleSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */; }; + 0BB721552A849A53AB9BF7CC /* RemoteCoordinateValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960353A6F38DD290112A2FE1 /* RemoteCoordinateValidationTests.swift */; }; 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; 0F2992C16A3A658DEA0F707E /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */; }; 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; }; @@ -401,6 +402,7 @@ 9406C27662147CD3C0783644 /* GameViewedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewedStoreTests.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureGameEntityTests.swift; sourceTree = "<group>"; }; + 960353A6F38DD290112A2FE1 /* RemoteCoordinateValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCoordinateValidationTests.swift; sourceTree = "<group>"; }; 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenterTests.swift; sourceTree = "<group>"; }; 98E68FD021EF8ED50BC706EE /* CloudContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudContainer.swift; sourceTree = "<group>"; }; 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesAnnouncementTests.swift; sourceTree = "<group>"; }; @@ -818,6 +820,7 @@ 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */, BD32BDDE9B007B11BE86B7A0 /* PushCredentialRotationTests.swift */, + 960353A6F38DD290112A2FE1 /* RemoteCoordinateValidationTests.swift */, F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */, 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */, A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */, @@ -1085,6 +1088,7 @@ 6E36ED34ACF047BABB3E2D69 /* RecentChangesTests.swift in Sources */, 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, + 0BB721552A849A53AB9BF7CC /* RemoteCoordinateValidationTests.swift in Sources */, 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */, C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */, AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -815,6 +815,13 @@ final class GameStore { + Self.peerChangeSampleSummary(upserts) ) for change in upserts { + // Positions originate in peer-controlled Moves payloads; + // the codec guarantees Int16 representability, and this + // keeps out-of-grid leftovers from becoming ledger rows. + guard change.position.isPersistable( + gridWidth: game.gridWidth, + gridHeight: game.gridHeight + ) else { continue } let row = rowByPosition[change.position] ?? PeerChangeEntity(context: ctx) row.gameID = gameID row.row = Int16(change.position.row) @@ -926,6 +933,7 @@ final class GameStore { } var applied = 0 + var rejected = 0 var touchedGameIDs: Set<UUID> = [] for (recordName, groupEdits) in groups { guard let sample = groupEdits.first, @@ -935,6 +943,20 @@ final class GameStore { let entity = fetchGameEntity(id: sample.gameID) else { continue } + // A live edit's coordinates are attacker-controlled over the + // relay channel, like its `updatedAt` below. Reject anything + // outside the grid before it reaches the persisted Moves state, + // whose Int16 cache sinks trap on overflow — and before an + // all-rejected batch can insert an empty MovesEntity stub. + let validEdits = groupEdits.filter { edit in + GridPosition(row: edit.row, col: edit.col).isPersistable( + gridWidth: entity.gridWidth, + gridHeight: entity.gridHeight + ) + } + rejected += groupEdits.count - validEdits.count + guard !validEdits.isEmpty else { continue } + let movesEntity = ensureMovesEntity( recordName: recordName, game: entity, @@ -949,7 +971,7 @@ final class GameStore { var latest = movesEntity.updatedAt ?? .distantPast var changed = false - for edit in groupEdits { + for edit in validEdits { let position = GridPosition(row: edit.row, col: edit.col) let clampedUpdatedAt = min(edit.updatedAt, maxAcceptableUpdatedAt) let incoming = TimestampedCell( @@ -978,6 +1000,12 @@ final class GameStore { touchedGameIDs.insert(sample.gameID) } + if rejected > 0 { + eventLog?.note( + "GameStore: rejected \(rejected) out-of-grid realtime cell edit(s)", + level: "error" + ) + } guard applied > 0 else { return 0 } saveContext("applyRealtimeCellEdits") if let openID = currentEntity?.id, touchedGameIDs.contains(openID) { @@ -1555,8 +1583,20 @@ final class GameStore { ) for row in (try? ctx.fetch(stale)) ?? [] { ctx.delete(row) } + // Remote journal entries are peer-controlled; the codec already + // dropped Int16-unrepresentable coordinates, and this skips + // anything outside the recorded grid before it becomes a cached + // replay row. + var skippedOutOfGrid = 0 for journal in journals { for value in journal.entries { + guard value.position.isPersistable( + gridWidth: game.gridWidth, + gridHeight: game.gridHeight + ) else { + skippedOutOfGrid += 1 + continue + } let row = JournalEntity(context: ctx) MovesJournal.assign(value, to: row, gameID: gameID) row.sourceAuthorID = journal.key.authorID @@ -1564,6 +1604,13 @@ final class GameStore { row.game = game } } + if skippedOutOfGrid > 0 { + let message = "GameStore: replay cache skipped \(skippedOutOfGrid) " + + "out-of-grid remote journal entr(ies)" + Task { @MainActor [weak self] in + self?.eventLog?.note(message, level: "error") + } + } game.replayCacheComplete = true do { try ctx.save() @@ -2771,6 +2818,13 @@ final class GameStore { } for (position, cell) in grid { + // The merged grid can include peer-controlled positions; the codec + // guarantees Int16 representability, and this keeps out-of-grid + // leftovers from materializing as CellEntity rows. + guard position.isPersistable( + gridWidth: gameEntity.gridWidth, + gridHeight: gameEntity.gridHeight + ) else { continue } let ce: CellEntity if let found = existing[position] { ce = found diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift @@ -609,11 +609,16 @@ enum JournalCodec { static func decode(_ data: Data) throws -> [JournalValue] { let payload = try JSONDecoder().decode(Payload.self, from: data) - return payload.entries.map { entry in - JournalValue( + return payload.entries.compactMap { entry in + let position = GridPosition(row: entry.row, col: entry.col) + // A remote journal's coordinates are attacker-controlled; entries + // that can't survive `assign`'s Int16 conversions are dropped here + // so they never reach the replay cache. + guard position.isInt16Representable else { return nil } + return JournalValue( seq: entry.seq, timestamp: entry.timestamp, - position: GridPosition(row: entry.row, col: entry.col), + position: position, beforeState: nil, state: JournalCellState( letter: entry.letter, diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -432,6 +432,9 @@ enum Archive { entity.replayCacheComplete = true for cell in payload.cells { + // The payload is peer-controlled; its Int16 fields can't overflow + // (decode throws first) but negatives must not become cache rows. + guard cell.row >= 0, cell.col >= 0 else { continue } let row = CellEntity(context: ctx) row.game = entity row.row = cell.row diff --git a/Crossmate/Sync/Moves.swift b/Crossmate/Sync/Moves.swift @@ -7,6 +7,25 @@ struct GridPosition: Hashable, Sendable, Codable { let col: Int } +extension GridPosition { + /// `true` when both coordinates survive the trapping `Int16` conversions + /// every Core Data cell/journal sink performs. The remote codecs drop + /// entries that fail this, so a forged record can't persist a value that + /// crashes cache replay on every subsequent fetch. + var isInt16Representable: Bool { + row >= 0 && col >= 0 && row <= Int(Int16.max) && col <= Int(Int16.max) + } + + /// `isInt16Representable` plus grid-shape bounds, for sinks that know the + /// game's dimensions. Zero dimensions (a row that predates the recorded + /// grid size) skip the shape check rather than rejecting everything. + func isPersistable(gridWidth: Int16, gridHeight: Int16) -> Bool { + guard isInt16Representable else { return false } + guard gridWidth > 0, gridHeight > 0 else { return true } + return row < Int(gridHeight) && col < Int(gridWidth) + } +} + /// Per-cell state in the merged grid — the result of `GridStateMerger.merge`. /// `authorID` is the *preserved* cell author from the winning entry. struct GridCell: Equatable, Sendable, Codable { @@ -163,6 +182,10 @@ enum MovesCodec { var cells: [GridPosition: TimestampedCell] = [:] for entry in payload.entries { let position = GridPosition(row: entry.row, col: entry.col) + // A remote payload's coordinates are attacker-controlled; entries + // that can't survive the sinks' Int16 conversions are dropped here + // so they never reach persistence or cache replay. + guard position.isInt16Representable else { continue } cells[position] = TimestampedCell( letter: entry.letter, mark: entry.mark, diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -413,7 +413,13 @@ extension SyncEngine { entity.name = renderedName } entity.updatedAt = updatedAt - if let selection = RecordSerializer.parsePlayerSelection(from: record) { + // A peer's published selection is remote-controlled; a coordinate + // outside the grid is treated as no selection rather than persisted. + if let selection = RecordSerializer.parsePlayerSelection(from: record), + GridPosition(row: selection.row, col: selection.col).isPersistable( + gridWidth: entity.game?.gridWidth ?? 0, + gridHeight: entity.game?.gridHeight ?? 0 + ) { entity.selRow = NSNumber(value: Int64(selection.row)) entity.selCol = NSNumber(value: Int64(selection.col)) entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) @@ -484,7 +490,16 @@ extension SyncEngine { byPosition[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell } + // Merged positions come from remote-controlled Moves payloads; the + // codec already dropped Int16-unrepresentable entries, and this skips + // anything outside the recorded grid so hostile coordinates never + // materialize as CellEntity rows. + var skippedOutOfGrid = 0 for (pos, gridCell) in gridState { + guard pos.isPersistable(gridWidth: game.gridWidth, gridHeight: game.gridHeight) else { + skippedOutOfGrid += 1 + continue + } let cell: CellEntity if let existing = byPosition[pos] { cell = existing @@ -504,6 +519,12 @@ extension SyncEngine { cell.markCode = 0 cell.letterAuthorID = nil } + guard skippedOutOfGrid == 0 else { + return [ + "replayCellCache \(gameID.uuidString.prefix(8)): " + + "skipped \(skippedOutOfGrid) out-of-grid remote cell(s)" + ] + } return [] } diff --git a/Tests/Unit/Sync/RemoteCoordinateValidationTests.swift b/Tests/Unit/Sync/RemoteCoordinateValidationTests.swift @@ -0,0 +1,234 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// H2 regression coverage: remote Moves/Journal records and realtime frames +/// carry attacker-controlled coordinates, and every Core Data cell/journal +/// sink converts them with trapping `Int16` initializers. A single poisoned +/// value used to crash cache replay on every subsequent fetch — a persistent +/// remote denial of service. These tests pin the validation that keeps +/// malformed coordinates out of persistence at each sink. +@Suite("Remote coordinate validation") +struct RemoteCoordinateValidationTests { + + private func cell(_ letter: String) -> TimestampedCell { + TimestampedCell( + letter: letter, + mark: .none, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + authorID: "alice" + ) + } + + @Test("isPersistable accepts in-grid and rejects out-of-grid coordinates") + func isPersistableBounds() { + let width: Int16 = 3 + let height: Int16 = 5 + #expect(GridPosition(row: 0, col: 0).isPersistable(gridWidth: width, gridHeight: height)) + #expect(GridPosition(row: 4, col: 2).isPersistable(gridWidth: width, gridHeight: height)) + // One past each edge. + #expect(!GridPosition(row: 5, col: 0).isPersistable(gridWidth: width, gridHeight: height)) + #expect(!GridPosition(row: 0, col: 3).isPersistable(gridWidth: width, gridHeight: height)) + #expect(!GridPosition(row: -1, col: 0).isPersistable(gridWidth: width, gridHeight: height)) + #expect(!GridPosition(row: 0, col: -1).isPersistable(gridWidth: width, gridHeight: height)) + // Zero dimensions (legacy rows) skip the shape check but still + // require Int16 representability. + #expect(GridPosition(row: 500, col: 500).isPersistable(gridWidth: 0, gridHeight: 0)) + #expect(!GridPosition(row: 32768, col: 0).isPersistable(gridWidth: 0, gridHeight: 0)) + #expect(!GridPosition(row: -1, col: 0).isPersistable(gridWidth: 0, gridHeight: 0)) + #expect(GridPosition(row: 32767, col: 32767).isPersistable(gridWidth: 0, gridHeight: 0)) + } + + @Test("MovesCodec.decode drops Int16-unrepresentable coordinates") + func movesCodecDropsUnrepresentable() throws { + let cells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): cell("A"), + GridPosition(row: 32768, col: 0): cell("B"), + GridPosition(row: 0, col: Int.max): cell("C"), + GridPosition(row: -1, col: 0): cell("D"), + GridPosition(row: 32767, col: 32767): cell("E"), + ] + + let decoded = try MovesCodec.decode(MovesCodec.encode(cells)) + + #expect(decoded.count == 2) + #expect(decoded[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(decoded[GridPosition(row: 32767, col: 32767)]?.letter == "E") + } + + @Test("JournalCodec.decode drops Int16-unrepresentable coordinates") + func journalCodecDropsUnrepresentable() throws { + let values = [ + journalValue(seq: 0, row: 0, col: 1, letter: "A"), + journalValue(seq: 1, row: 32768, col: 0, letter: "B"), + journalValue(seq: 2, row: 0, col: -5, letter: "C"), + ] + + let decoded = try JournalCodec.decode(JournalCodec.encode(values)) + + #expect(decoded.count == 1) + #expect(decoded.first?.position == GridPosition(row: 0, col: 1)) + #expect(decoded.first?.state.letter == "A") + } + + @Test("parseMovesRecord strips poisoned coordinates from a hostile record") + func parseMovesRecordStripsPoisonedCells() throws { + let gameID = UUID() + let value = MovesValue( + gameID: gameID, + authorID: "bob", + deviceID: "d1", + cells: [ + GridPosition(row: 1, col: 1): cell("A"), + GridPosition(row: 40_000, col: 0): cell("B"), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let record = try RecordSerializer.movesRecord( + from: value, + zone: RecordSerializer.zoneID(for: gameID), + systemFields: nil + ) + + let parsed = try #require(RecordSerializer.parseMovesRecord(record)) + + #expect(parsed.cells.count == 1) + #expect(parsed.cells[GridPosition(row: 1, col: 1)]?.letter == "A") + } +} + +/// The Core Data sinks: realtime edits merged into `MovesEntity` state and +/// remote journals cached as replay rows, both against a 3×3 grid. +@Suite("Remote coordinate validation — store sinks", .serialized) +@MainActor +struct RemoteCoordinateStoreSinkTests { + + private static let puzzleSource = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + private func makeGame(in ctx: NSManagedObjectContext) throws -> UUID { + let gameID = UUID() + let xd = try XD.parse(Self.puzzleSource) + let puzzle = Puzzle(xd: xd) + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Shared" + entity.puzzleSource = Self.puzzleSource + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + entity.populateCachedSummaryFields(from: puzzle) + try ctx.save() + return gameID + } + + private func edit(gameID: UUID, row: Int, col: Int, letter: String) -> RealtimeCellEdit { + RealtimeCellEdit( + gameID: gameID, + authorID: "alice", + deviceID: "remote-device", + row: row, + col: col, + letter: letter, + mark: .none, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + cellAuthorID: "alice" + ) + } + + @Test("Realtime edits with malformed coordinates are rejected, valid ones apply") + func realtimeEditsRejectMalformedCoordinates() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = try makeGame(in: persistence.viewContext) + + let applied = store.applyRealtimeCellEdits([ + edit(gameID: gameID, row: 0, col: 0, letter: "Q"), + edit(gameID: gameID, row: 3, col: 0, letter: "X"), // one past the grid + edit(gameID: gameID, row: 0, col: -1, letter: "X"), // negative + edit(gameID: gameID, row: 40_000, col: 0, letter: "X"), // would trap Int16 + ]) + + #expect(applied == 1) + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + let rows = try persistence.viewContext.fetch(req) + #expect(rows.count == 1) + let cells = try MovesCodec.decode(try #require(rows.first?.cells)) + #expect(cells.count == 1) + #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "Q") + } + + @Test("A batch of only malformed realtime edits applies nothing") + func allMalformedRealtimeBatchAppliesNothing() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = try makeGame(in: persistence.viewContext) + + let applied = store.applyRealtimeCellEdits([ + edit(gameID: gameID, row: 3, col: 3, letter: "X"), + edit(gameID: gameID, row: Int.max, col: 0, letter: "X"), + ]) + + #expect(applied == 0) + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + #expect(try persistence.viewContext.fetch(req).isEmpty) + } + + @Test("Replay cache skips remote journal entries outside the grid") + func replayCacheSkipsOutOfGridEntries() async throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = try makeGame(in: persistence.viewContext) + + let journal = DeviceJournal( + key: JournalDeviceKey(authorID: "bob", deviceID: "dev-b"), + entries: [ + journalValue(seq: 0, row: 0, col: 2, letter: "A"), + journalValue(seq: 1, row: 100, col: 0, letter: "B"), // out of grid + journalValue(seq: 2, row: 40_000, col: 0, letter: "C"), // would trap Int16 + ] + ) + + await store.cacheRemoteJournals([journal], forGameID: gameID) + + let cached = try #require(await store.cachedRemoteJournals(forGameID: gameID)) + #expect(cached.count == 1) + #expect(cached.first?.entries.count == 1) + #expect(cached.first?.entries.first?.position == GridPosition(row: 0, col: 2)) + } +} + +/// Shared journal fixture builder for both suites above. +private func journalValue(seq: Int64, row: Int, col: Int, letter: String) -> JournalValue { + JournalValue( + seq: seq, + timestamp: Date(timeIntervalSince1970: TimeInterval(1_700_000_000 + seq)), + position: GridPosition(row: row, col: col), + state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "alice"), + actingAuthorID: "alice", + kind: .input, + targetSeq: nil, + batchID: nil, + prevSeqAtCell: nil, + direction: nil + ) +}