TunnelVision

Spec — Persistence (Shared/Persistence)

Durable history in SQLite via GRDB, in the App Group container so both processes reach it. The extension writes (batched); the app reads. WAL mode makes concurrent read-while-write safe. Rationale for GRDB over SwiftData/Core Data: ../decisions/0002-grdb-over-swiftdata.md.

Location

The DB lives at <AppGroupContainer>/TunnelVision.sqlite. The App Group ID constant lives in Shared/IPC (see ipc.md); resolve the container with FileManager.containerURL(forSecurityApplicationGroupIdentifier:).

Time is absolute on disk

The store is the boundary where time stops being relative. FlowRecord/PacketMeta carry CLOCK_UPTIME_RAW nanoseconds because that is the only clock the hot path has; the columns hold nanoseconds since the epoch, converted on write with the MonotonicAnchor the store takes when it opens (one per capture session). Storing the raw monotonic stamp would leave a history that cannot be dated or ordered across a reboot — the uptime restarts, the wall clock does not — and would make age-based retention plainly wrong.

Each flow also records its session (the store’s open instant, in epoch ns), and that column is part of the flows’ unique index. Ephemeral ports get recycled, so without it the same 5-tuple seen on two different days would collapse into one row spanning both, with a duration nobody ever observed.

Schema (via DatabaseMigrator)

public enum Schema {
    public static func migrator() -> DatabaseMigrator {
        var m = DatabaseMigrator()
        m.registerMigration("v1") { db in
            try db.create(table: "flows") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("proto", .integer).notNull()
                t.column("addr_a", .blob).notNull()
                t.column("port_a", .integer).notNull()
                t.column("addr_b", .blob).notNull()
                t.column("port_b", .integer).notNull()
                t.column("first_seen", .integer).notNull()
                t.column("last_seen", .integer).notNull()
                t.column("bytes_out", .integer).notNull().defaults(to: 0)
                t.column("bytes_in", .integer).notNull().defaults(to: 0)
                t.column("packet_count", .integer).notNull().defaults(to: 0)
                t.column("tls_status", .integer).notNull()
                t.column("sni", .text)
            }
            try db.create(index: "flows_last_seen", on: "flows", columns: ["last_seen"])
            // Índice único sobre la 5-tupla canónica: habilita el UPSERT por flujo (`ON CONFLICT`)
            // y las búsquedas `flow(matching:)` sin escaneo. No estaba en el esquema original;
            // se añadió al implementar M2 porque la API de upsert lo requiere.
            try db.create(
                index: "flows_tuple",
                on: "flows",
                columns: ["proto", "addr_a", "port_a", "addr_b", "port_b"],
                options: [.unique]
            )

            try db.create(table: "packets") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("flow_id", .integer).notNull()
                    .references("flows", onDelete: .cascade)
                t.column("ts", .integer).notNull()
                t.column("direction", .integer).notNull()
                t.column("length", .integer).notNull()
                t.column("tcp_flags", .integer).notNull().defaults(to: 0)
                t.column("pcap_offset", .integer).notNull().defaults(to: 0)
            }
            try db.create(index: "packets_flow_id", on: "packets", columns: ["flow_id"])
        }

        // v2 (M9) — los instantes pasan a ser absolutos y el flujo recuerda su sesión de captura.
        m.registerMigration("v2") { db in
            // Las filas de v1 llevan sellos monotónicos que ya no se pueden fechar (no existe el
            // ancla de la sesión que las escribió). Se descartan en vez de reinterpretarlas como
            // epoch, que las situaría en 1970 y mentiría en la Timeline.
            try db.execute(sql: "DELETE FROM flows")
            try db.alter(table: "flows") { t in
                t.add(column: "session", .integer).notNull().defaults(to: 0)
            }
            try db.drop(index: "flows_tuple")
            try db.create(
                index: "flows_session_tuple",
                on: "flows",
                columns: ["session", "proto", "addr_a", "port_a", "addr_b", "port_b"],
                options: [.unique]
            )
        }

        // v3 (M9) — cada paquete recuerda **en qué fichero** de captura están sus bytes.
        m.registerMigration("v3") { db in
            try db.alter(table: "packets") { t in
                t.add(column: "pcap_file", .integer).notNull().defaults(to: 0)
            }
            // Las filas anteriores llevan offset pero no fichero, así que su offset no señala nada:
            // se anula (que es como se representa "sin captura") en vez de dejarlo apuntando al
            // fichero 0, que sería inventarles unos bytes. Sus metadatos se conservan.
            try db.execute(sql: "UPDATE packets SET pcap_offset = 0")
        }

        // v4 (M9) — índice por instante en `packets`, para el eje temporal de la Timeline.
        m.registerMigration("v4") { db in
            try db.create(index: "packets_ts", on: "packets", columns: ["ts"])
        }

        // v5 (M8) — el índice del **contenido descifrado**: qué trozo de qué conversación está en
        // qué fichero y en qué posición. Los bytes no están aquí (ver `plaintext.md`).
        m.registerMigration("v5") { db in
            try db.create(table: "plaintext") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("flow_id", .integer).notNull().references("flows", onDelete: .cascade)
                t.column("ts", .integer).notNull()
                t.column("direction", .integer).notNull()
                t.column("stream", .integer).notNull()        // la conversación dentro del fichero
                t.column("file_seq", .integer).notNull()
                t.column("record_offset", .integer).notNull() // `offset` es palabra reservada
                t.column("stored_length", .integer).notNull()
                t.column("original_length", .integer).notNull()
            }
            try db.create(index: "plaintext_flow_id", on: "plaintext", columns: ["flow_id", "ts"])
            try db.create(index: "plaintext_ts", on: "plaintext", columns: ["ts"])
        }

        // v6 — proyectos, sesiones y marcadores de auditoría, y `flows.audit_session_id`.
        // El layout y el porqué de cada regla están en `audit.md`.
        m.registerMigration("v6") { db in /* audit_projects, audit_allowlist, audit_sessions,
                                             audit_markers, flows.audit_session_id */ }

        // v7 — el nombre que el DNS le había dado a la dirección remota del flujo. Columnas propias,
        // nunca la `sni`: aquélla la anuncia la conexión y esto se deduce.
        m.registerMigration("v7") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "dns_name", .text)
                t.add(column: "dns_other_names", .text)   // separados por un espacio; NULL = ninguno
            }
        }

        // v8 — lo que el servidor contestó al ClientHello: versión y suite, o la alerta. Los valores
        // del cable, sin tabla detrás. Las cuatro `NULL` = no hubo lectura.
        m.registerMigration("v8") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "tls_version", .integer)
                t.add(column: "tls_cipher_suite", .integer)
                t.add(column: "tls_hello_retry", .integer)   // 1 = salió de un HelloRetryRequest
                t.add(column: "tls_alert", .integer)         // excluye a las otras tres
            }
        }

        // v9 — lo que un flujo llevaba acumulado antes de que la tabla en memoria lo volviera a
        // crear: el total de la fila es base + lo que diga el record.
        m.registerMigration("v9") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "base_bytes_out", .integer).notNull().defaults(to: 0)
                t.add(column: "base_bytes_in", .integer).notNull().defaults(to: 0)
                t.add(column: "base_packet_count", .integer).notNull().defaults(to: 0)
            }
        }

        // v10 — de dónde salió la versión y la suite: 1 = de la conexión que el túnel abrió contra
        // el servidor real para inspeccionar el flujo. NULL = sin versión, o leída del ServerHello.
        m.registerMigration("v10") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "tls_upstream", .integer)
            }
        }

        // v11 — lo que el cliente ofreció en su ClientHello. Una fila con oferta tiene exactamente
        // una de las dos primeras columnas: la lista de `supported_versions` o el `legacy_version`.
        m.registerMigration("v11") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "tls_offered_versions", .text)        // valores del cable, separados por espacios
                t.add(column: "tls_offered_legacy", .integer)
                t.add(column: "tls_offered_alpn", .text)            // separados por espacios
                t.add(column: "tls_offered_alpn_omitted", .integer)
                t.add(column: "tls_offered_ech", .integer)
            }
        }

        // v12 — la versión de QUIC de un flujo y de qué extremo se leyó. Juntas o las dos NULL.
        m.registerMigration("v12") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "quic_version", .integer)             // los cuatro bytes del cable
                t.add(column: "quic_from_server", .integer)         // 1 = la mandó el servidor
            }
        }

        // v13 — lo leído del certificado del servidor en un flujo de TLS ≤ 1.2.
        m.registerMigration("v13") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "tls_chain_state", .integer)          // 0 entera, 1 parcial, 2 sesión reanudada, 3 sin certificado
                t.add(column: "tls_chain", .text)                   // JSON; NULL si el estado no es una cadena
            }
        }

        // v14 — con qué empezó el stream saliente de un flujo TCP, en el puerto que sea.
        m.registerMigration("v14") { db in
            try db.alter(table: "flows") { t in
                t.add(column: "stream_opening", .integer)           // 1 TLS, 2 HTTP en claro, 3 ninguna; NULL sin lectura
            }
        }
        return m
    }
}

Addresses are stored as raw blob (4 or 16 bytes) so both IPv4/IPv6 fit one column and sort sensibly; the length disambiguates version.

Store API

public actor FlowStore {
    public enum StoreError: Error, Sendable, Equatable {
        case appGroupUnavailable   // no se pudo resolver el contenedor del App Group
        case openFailed            // el DatabasePool no abrió
        case migrationFailed       // fallaron las migraciones
        case corruptRow(String)    // una fila no reconstruye a un tipo de dominio
    }

    // El ancla convierte los sellos monotónicos a instantes absolutos al escribir, e identifica la
    // sesión de captura. Se inyecta para fechar de forma determinista en los tests.
    public init(appGroupID: String, anchor: MonotonicAnchor = .now()) throws
    public init(databaseURL: URL, anchor: MonotonicAnchor = .now()) throws

    // Escritura (extensión) — por lotes desde el read loop.
    @discardableResult
    public func upsertFlow(_ record: FlowRecord) throws -> Int64     // devuelve rowid
    public func appendPackets(_ metas: [PacketMeta], flowID: Int64) throws
    /// Igual de por lotes, y por lo mismo: esto ocurre en el camino de un paquete.
    public func appendPlaintext(_ chunks: [PlaintextChunkMeta], flowID: Int64) throws

    // Lectura (app) — devuelve tipos ya fechados, no los del hot path.
    public func recentFlows(limit: Int, before cursor: FlowCursor? = nil) throws -> [StoredFlow]
    public func packets(forFlow id: Int64, limit: Int) throws -> [StoredPacket]
    public func flow(matching key: FlowKey) throws -> StoredFlow?    // el más reciente
    /// Mayor secuencia de fichero que el historial referencia; la consulta `PcapWriter` al
    /// arrancar para no reutilizar la de un fichero borrado cuyos paquetes siguen guardados.
    public func highestCaptureFileSequence() throws -> UInt32?

    // Contenido descifrado (M8, ADR 0007). Los bytes viven en ficheros propios; esto es su índice.
    public func plaintext(forFlow id: Int64, limit: Int) throws -> [StoredPlaintextChunk]
    /// Lo que el escritor pregunta al arrancar para no reutilizar un número que el índice señala.
    public func highestPlaintextFileSequence() throws -> UInt32?
    public func highestPlaintextStream() throws -> UInt64?
    /// Qué ficheros siguen sirviendo. Se pregunta **después** de podar: los que ya no aparecen son
    /// huérfanos y son exactamente lo que el barrido tiene que borrar del disco.
    public func referencedPlaintextFileSequences() throws -> Set<UInt32>
    public func plaintextChunkCount() throws -> Int

    // Actividad (M9) — lo que dibuja la barra de scrub de la Timeline. No es paginación: es una
    // agregación acotada por rango, y por eso `packets` está indexada por `ts` desde v4.
    public func packetTimeBounds() throws -> ClosedRange<Date>?
    public func packetCounts(
        in range: ClosedRange<Date>, bucketDuration: TimeInterval
    ) throws -> [PacketBucket]

    // Mantenimiento.
    @discardableResult
    public func prune(before cutoff: Date) throws -> Int             // filas borradas
    /// Poda **propia** del contenido descifrado, que caduca antes que el historial que lo contiene
    /// (ADR 0007): el flujo se queda con sus paquetes y sus contadores, y lo que dijo por dentro no.
    @discardableResult
    public func prunePlaintext(before cutoff: Date) throws -> Int
    /// Borrar solo el contenido descifrado. Existe porque arrepentirse de haberlo grabado no puede
    /// costar el historial entero (ADR 0007).
    @discardableResult
    public func clearPlaintext() throws -> Int
    public func totalBytesOnDisk() throws -> Int64
    public func clearAll() throws                                    // el cascade se lleva el plaintext
}

public struct StoredFlow: Sendable, Hashable, Identifiable {
    public let id: Int64                 // rowid: con él se piden los paquetes del flujo
    public let key: FlowKey
    public let firstSeen: Date
    public let lastSeen: Date
    public let bytesOut: UInt64
    public let bytesIn: UInt64
    public let packetCount: UInt64
    public let tlsStatus: TLSInspectionStatus
    public let sni: String?
    public let resolvedName: ResolvedFlowName?   // `dns_name` / `dns_other_names`
    public let serverTLS: ServerTLSAnswer?       // `tls_version` / `tls_cipher_suite` / `tls_hello_retry` / `tls_upstream` / `tls_alert`
    public let serverCertificates: ServerCertificateReading?   // `tls_chain_state` / `tls_chain`
    public let clientTLS: ClientTLSOffer?        // las cinco columnas `tls_offered_*`
    public let quic: QUICVersionReading?         // `quic_version` / `quic_from_server`
    public let streamOpening: StreamOpening?     // `stream_opening`
    public var name: FlowName? { get }           // el nombre con su origen (`data-model.md`)
    public var duration: TimeInterval { get }
    public var totalBytes: UInt64 { get }
}

public struct StoredPacket: Sendable, Hashable, Identifiable {
    public let id: Int64                 // rowid: identidad estable para las listas de la UI
    public let date: Date
    public let flowKey: FlowKey
    public let direction: Direction
    public let length: UInt32
    public let tcpFlags: TCPFlags
    /// Fichero + offset, o `nil` si no se capturó. El centinela en disco es el offset: `pcap_offset
    /// = 0` no puede ser un registro (todo `.pcap` empieza por su cabecera global), y entonces
    /// `pcap_file` vale 0 sin significar nada.
    public let capture: CaptureLocation?
}

/// Cuántos paquetes cayeron en un intervalo. Solo existen los intervalos **con** paquetes: rellenar
/// los huecos a cero es de quien pinta, que es el único que sabe cuántas barras caben.
public struct PacketBucket: Sendable, Hashable {
    public let start: Date
    public let packetCount: Int
}

/// Lleva el `id` además del instante porque `last_seen` no es único: sin el desempate, una página
/// podría repetir o saltarse filas justo en el corte.
public struct FlowCursor: Sendable, Hashable {
    public let lastSeen: Date
    public let id: Int64
    public init(after flow: StoredFlow)
}

Los tipos de lectura son propios y no FlowRecord/PacketMeta para que un UInt64 no signifique una cosa viniendo de la tabla de flujos en memoria y otra viniendo de disco: si sale del store, es una Date.

El init(appGroupID:) recibe el ID del App Group como parámetro (la constante única AppGroup.identifier vive en Shared/IPC, pendiente de M5) y resuelve la ruta <container>/TunnelVision.sqlite. El spec original preveía un clock: MonotonicClock en el init; se omitió en M2 porque el store no lo usa (los timestamps de dominio viajan dentro de PacketMeta/FlowRecord) y un parámetro muerto contradice la regla de “cero placeholders”. Se introducirá un reloj monotónico cuando lo necesite el código productor (parseo/túnel, M3/M7).

The audit half

Since v6 the same database holds the audit projects, sessions and markers of the TR-03161 workflow, and upsertFlow tags every flow it writes with the open audit session, read from the database in the same statement. That API (FlowStore+Audit.swift), its schema and the rules for which session a flow belongs to are specified in audit.md.

Concurrency

FlowStore is an actor; GRDB’s DatabasePool provides a writer queue and multiple readers underneath. Never share a raw Database handle across actors — go through the store API.

Tests (M2, extended in M9)