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.
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:).
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.
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.
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).
journal_mode=WAL at open so the app reads while the extension writes.PacketMeta and flushes with appendPackets
in a single transaction; per-packet inserts are forbidden on the hot path.upsertFlow inserts a new flow or, keyed by the canonical 5-tuple, updates
an existing one, and returns the flow’s rowid for linking its packets. last_seen is set to the
record’s and first_seen keeps the minimum seen.v9). The table lets go of a flow that is silent for two minutes, or evicts it, or closes it on a
RST, and creates it again from zero if that 5-tuple has traffic again. Setting the row to the
record’s values — what the upsert did until v9 — erased everything before: a connection that had
moved megabytes was left on record with one packet, without its SNI, and back to encrypted if it
had been inspected. So:
base_* + the record’s. A new life is recognised because it starts after the
last thing the row saw (the record’s first_seen > the row’s last_seen; within one life it is
the other way round by construction), and at that moment the row’s totals become the base. Later
writes of that life bring running totals again and are added to the same base — once, not once
per write.sni is kept when the record brings none; a new one replaces it.tls_status does not fall from an outcome (inspected / notInspectable) to a starting
state (plaintext / encrypted), which is what every flow is born with. A new outcome does
replace the previous one.firstSeen rather than by arrival — the closing record of a life is drained at flush time,
after a packet of the next one may already have come in — and they are written oldest first.v7): a row that already has a
dns_name keeps it. A flow is named when it is created, and the in-memory table can create the same
flow twice — it is evicted or goes idle and then has traffic again — at which point the name map may
answer something else; the history must not rename a connection halfway. dns_name and
dns_other_names are kept or replaced together, so one name’s candidates never end up under
another. A row without a name takes the first one that arrives.dns_other_names is one text column, the names joined by a single space, most recent first,
NULL when there are none. A name from the map cannot contain a space (DomainPattern is its
yardstick), so no escaping is needed. There is no origin column: the origin of a flow’s name is
which of sni and dns_name is set.v8): a record that brings
none does not erase the one the row has, and a record that brings one replaces it whole. A
ServerHello is read once, at the start of the stream; a flow the in-memory table creates a second
time will not see it go by again, so it arrives without an answer and the row must keep its own.
If it does arrive with one there was a new handshake on the same 5-tuple, and the latest is the one
that holds. The five columns move together — a version never ends up beside another answer’s alert,
nor under another reading’s source.tls_upstream, v10), because it is not
derivable from tls_status and it changes what the version means
(relay-and-tls.md § What an inspected flow’s server chose). NULL reads as
the ServerHello: before v10 nothing else wrote a version, so earlier rows need no backfill.tls_alert set means refused and the other four are NULL; otherwise
tls_version and tls_cipher_suite are both set or both NULL. A row that has one without the
other, or a value that does not fit the two bytes it came from, is a corruptRow, not a guess.
Alert 0 is a valid code: the test is NULL, never zero.v11): the
ClientHello is read once, so a record that brings no offer keeps the row’s and one that brings an
offer replaces all five tls_offered_* columns together. The offer and the answer are independent
of each other: neither overwrites the other.relay-and-tls.md § What the client offered). tls_offered_versions is the
supported_versions list — exact —, as the wire values in decimal joined by a space, in the
client’s order; tls_offered_legacy is the legacy_version of a ClientHello without that
extension — a ceiling. A row with an offer has exactly one of them, and that pair is the test
for “is there an offer”: the other three columns cannot say (a client without ALPN leaves them as
an unread row would). An empty list is '', not NULL — the extension was there and offered
nothing. Both set, a version that is not a number or does not fit two bytes, or an offer without
its omitted count, is a corruptRow.tls_offered_alpn is one text column, the identifiers joined by a single space, NULL when
there are none. The scanner only keeps printable ASCII without spaces, so no escaping is needed;
what it did not keep is counted in tls_offered_alpn_omitted.v12,
packet-parsing.md § Above L4: the QUIC long header). The long header only
travels at the start of a connection, so a record without a reading keeps the row’s and one with a
reading replaces both columns. And a row that has a QUIC version does not go back to
plaintext because a record born that way arrives: that record is the second life of a UDP flow
the in-memory table created again, which never saw the header. Without this the longest flow of a
session — a video — would end up labelled as cleartext the first time the table evicted it.
quic_version is the wire value (it does not fit a signed 32-bit integer; SQLite’s is 64);
quic_from_server is 1 for a server reading, 0 for a client one. A version of 0, one out of
range, or one without its source is a corruptRow. There is no “is QUIC” column: tls_status
already says what was concluded from the version.v14,
relay-and-tls.md § How a stream opened). It is only seen at the start of
a connection, so a record without a reading keeps the row’s and one with a reading replaces it.
And a row whose opening was a TLS handshake does not go back to plaintext because a record
born that way arrives without an opening of its own: that is the second life of a TLS flow on
a port other than 443, which the port calls plaintext. A record that does bring its own
opening is a new connection on the same 5-tuple and sets its own status. stream_opening is
1 a TLS handshake, 2 an HTTP request, 3 neither, NULL no reading; any other value is a
corruptRow.v13,
relay-and-tls.md § The certificate chain of TLS ≤ 1.2). A record that
brings nothing read from the server — no version, no alert, no chain — keeps the row’s
chain: it is a flow the in-memory table created again. A record that brings any of them sets the
chain to its own, even when that is none. Kept separately, a new TLS 1.3 handshake on the
same 5-tuple would inherit the previous connection’s chain, and the history would say a 1.3 flow
presented in the clear a certificate nobody could have seen. tls_chain_state is 0 a whole
chain, 1 a chain of which only the beginning is kept, 2 a resumed session, 3 a handshake
without a certificate, NULL no reading — and NULL does not say why; that TLS 1.3 encrypts it
is derived from tls_version (ServerCertificateVisibility), not written here. tls_chain is a
JSON array of {subject, subjectTruncated, issuer, issuerTruncated, notAfter} in the order sent,
notAfter in seconds since 1970. It is JSON, unlike the other lists, because a subject is text
the server chooses and may contain anything; it has its own stored type, so renaming a model
property does not change what is on disk. An unknown state, a chain state without its JSON, or
JSON that does not decode is a corruptRow.prune(before:) enforces the user’s storage cap; the app exposes it in Settings →
Storage. The cutoff is a Date precisely because retention is about real age, which a monotonic
stamp cannot express across a reboot. ON DELETE CASCADE removes a flow’s packets automatically.
A flow tagged with an audit session is skipped, however old: it is evidence, and stays until its
session is deleted (audit.md § Retention).
flowCount() goes with it (M9): it is the figure Settings shows next to the bytes, because a database
size means nothing to a user and a number of connections does, and what clearAll() is about to remove
can only be counted before it runs. Deleting rows never deletes capture files — that half belongs to
StorageManager (app-services.md), which is what makes a cleanup honest.packetTimeBounds() and packetCounts(in:bucketDuration:) are what the
Timeline’s scrub bar is drawn from, and the store’s first aggregate read — everything else here
returns rows. Three decisions. (1) It counts packets, not flows alive per interval. A packet has
its own stamp and an indexed column, so the counts are a GROUP BY over a range; a flow spans
intervals, and counting those would mean matching every row against every bucket (the same overlap
HistoryFilter does in memory). (2) The buckets are aligned to the range’s start, not to the
epoch, so ts - start is never negative and SQLite’s integer division — which truncates toward zero
rather than down — is the floor for every row that qualifies. What the user reads off a bar is its
real interval, so the alignment never lies to them. (3) It honours no filter at all. The counts
cover everything stored, including the connections the list is hiding: the host filter is resolved
in memory over the displayed host, so honouring some criteria and not others would leave an axis
that looks filtered without being it. Saying so belongs to the screen.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.
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.
flow(matching:) returns the newer.v7): it round-trips with its other names in order and never as the sni; every
flow query returns it; a row that has one keeps it, with its own candidates, when a later record
brings another or none; a row without one takes the one that arrives; and a database stopped at
v6 migrates keeping its flows, unnamed.v8): a negotiation round-trips with its HelloRetryRequest mark and with
values no table knows; a refusal round-trips with its code, alert 0 included; every flow query
returns it; a record without one does not erase the row’s; a new one replaces the previous whole in
both directions; a half-written or out-of-range answer is a corruptRow; and a database stopped at
v7 migrates keeping its flows, without an answer.v10): an upstream reading round-trips as one; a new answer replaces the source with
the rest, in both directions; a refusal leaves no source behind; and a version written before
v10 reads back as coming from the ServerHello.v11): a listed offer round-trips in order with its ALPN, its omitted count
and its ECH mark; a ceiling reads back as a ceiling, not as a list of one; an empty list is still an
offer; a version no table knows is kept by value; every flow query returns it; a record without one
does not erase the row’s; a new one replaces the previous whole in both directions; it and the
server’s answer do not overwrite each other; four shapes of corrupt row; and a database stopped at
v10 migrates keeping its flows, without an offer.v12): a reading round-trips from both ends and at the top of its four bytes; a
record without one does not erase the row’s; a flow created again does not fall back to
plaintext and still adds its totals; a row without a version takes its record’s status as
before; a new reading replaces the previous whole; a version without its source is a corruptRow;
and a database stopped at v11 migrates keeping its flows, without a version.v14): every case round-trips; a flow without one reads back without one; a
flow created again keeps its opening, does not fall back to plaintext and still adds its
totals; only a TLS opening holds the status; a new opening replaces the previous one and its
status; the audit-session query returns it; an unknown code is a corruptRow; the documented
codes, asserted against the column; and a database stopped at v13 migrates keeping its flows.v13): a chain, an incomplete one with truncated names, an empty one and
both reasons for none round-trip; names with quotes, backslashes and non-ASCII text come back as
written; every flow query returns it, the audit-session one included; a chain that arrives after
the answer is added; a record with nothing read from the server keeps it; a new answer without a
chain drops it; a new reading replaces it; four shapes of corrupt row; the documented state
values, asserted against the column; and a database stopped at v12 migrates keeping its flows.v9): its totals are added to what the row had, later writes of the second
life are not added twice, a third life builds on both, the SNI survives a life that brings none
and is replaced by a new one, an inspection outcome is not undone by a starting state and is
replaced by a new outcome; and a database stopped at v8 migrates keeping its totals.recentFlows/packets pagination and ordering.prune deletes the right rows and cascades to packets; totalBytesOnDisk is sane.flowCount counts what is stored, does not count an upsert onto an existing row as a new connection,
and follows both prune and clearAll.packetTimeBounds is nil without packets (a flow alone is not enough) and spans every flow;
packetCounts groups across flows, omits the empty intervals, aligns to the range’s start, honours
both ends of the range, ignores protocol and TLS status, and follows prune.