TunnelVision

Spec — Audit model (Shared/Audit, schema v6)

The data model of the TR-03161 network evidence workflow: what is audited, each recording of it, and the instants marked inside a recording, the classifier that turns a recording’s flows into findings (§ Findings), the comparison of two recordings of the same project (§ Release diff), the catalogue that ties findings to the requirements of a document (§ Requirement catalogue), and the documents of the bundle a session is exported as (§ Evidence bundle). Scope and attribution method: ../decisions/0008-tr03161-audit-workflow-scope.md.

The value types live in Shared/Audit; their storage is the audit half of FlowStore (Shared/Persistence/FlowStore+Audit.swift), in the same database as the history (persistence.md).

Two things are called “session”

  Capture session Audit session
What it is The instant a FlowStore was opened A recording a person starts, names and ends
Column flows.session flows.audit_session_id
Why it exists So a recycled 5-tuple is not merged into one flow So a flow can be reported as evidence of one audit
How many One per tunnel start At most one open at a time, most traffic belongs to none

An audit session can span several capture sessions (the tunnel restarts in the middle) — which is why it is a column of its own and not a reuse of the existing one.

Types

/// An allowlist pattern: an exact name or `*.suffix`. Normalised and validated on construction.
public struct DomainPattern: Sendable, Hashable {
    public enum Scope: Sendable, Hashable { case exact, subdomains }
    public enum ParseError: Error, Sendable, Equatable {
        case empty, misplacedWildcard, nonASCII, emptyLabel
        case labelTooLong(String), nameTooLong, invalidCharacter(Character)
    }
    public let scope: Scope
    public let name: String                       // normalised, without the `*.` prefix
    public init(parsing text: String) throws
    public var text: String                       // canonical form: what is stored and shown
    public func matches(_ host: String) -> Bool
}

public struct AllowlistEntry: Sendable, Hashable {
    public let pattern: DomainPattern
    public let note: String?                      // "backend", "crash reporting" — goes to the report
}

public struct AuditProject: Sendable, Hashable, Identifiable {
    public let id: Int64
    public let name: String
    public let bundleIdentifier: String?          // informational, never filters traffic
    public let catalogueVersion: String?          // nil until a requirement catalogue is chosen
    public let allowlist: [AllowlistEntry]        // in the order it was written
    public let createdAt: Date
    public func allowlistEntry(matching host: String) -> AllowlistEntry?
}

public struct AppRelease: Sendable, Hashable { public let version: String; public let build: String }

public enum AuditSessionKind: Sendable, Hashable {
    case baseline                                 // recorded without the audited app
    case audit(AppRelease)
    public var release: AppRelease? { get }
}

public struct AuditEnvironment: Sendable, Hashable {
    public let deviceModel: String
    public let osVersion: String
    public let toolVersion: String
}

public struct InspectionConditions: Sendable, Hashable {
    public let inspectionEnabled: Bool
    public let caTrusted: Bool
    public var supportsPinningEvidence: Bool { get }   // both, or pinning is not assessed
}

public struct AuditSession: Sendable, Hashable, Identifiable {
    public let id: Int64
    public let projectID: Int64
    public let kind: AuditSessionKind
    public let environment: AuditEnvironment
    public let inspection: InspectionConditions
    public let startedAt: Date
    public let endedAt: Date?                     // nil while open
    public let notes: String
    public var isOpen: Bool { get }
}

public enum SessionMarkerKind: Sendable, Hashable {
    case consentGiven, loggedIn, loggedOut, custom(String)
}

public struct SessionMarker: Sendable, Hashable, Identifiable {
    public let id: Int64
    public let sessionID: Int64
    public let date: Date
    public let kind: SessionMarkerKind
}

/// The open session with its project's name: what a system control needs to say where it would mark.
public struct AuditRecording: Sendable, Hashable {
    public let session: AuditSession
    public let projectName: String
}

public enum OpenSessionMarkerOutcome: Sendable, Hashable {
    case placed(SessionMarker, in: AuditRecording)
    case noOpenSession
}

AuditProjectDraft and AuditSessionDraft carry everything but what the store assigns (the row id and the instant, which is always passed in explicitly).

Rules that are not obvious

Schema v6

audit_projects   id, name, bundle_id?, catalogue_version?, created_at
audit_allowlist  id, project_id → audit_projects (cascade), position, pattern, note?
                 UNIQUE (project_id, pattern)
audit_sessions   id, project_id → audit_projects (cascade), kind, app_version?, build_number?,
                 device_model, os_version, tool_version, inspection_enabled, ca_trusted,
                 started_at, ended_at?, notes
                 UNIQUE INDEX audit_sessions_single_open ((ended_at IS NULL)) WHERE ended_at IS NULL
audit_markers    id, session_id → audit_sessions (cascade), ts, kind, label?
flows            + audit_session_id? → audit_sessions (SET NULL)
                 INDEX flows_audit_session (audit_session_id, first_seen)

Instants are nanoseconds since the epoch, like everywhere else on disk. kind columns are integers whose meaning lives in AuditSerialization, not in the types: they are disk format.

Which session a flow belongs to

upsertFlow reads the open audit session from the database, in the same statement that writes the flow. The app opens a session by inserting a row; the extension needs no message telling it so, because the shared database already is that message.

Which capture files belong to a session

Capture files do not carry the session anywhere. It is derived: captureFileSequences(inAuditSession:) returns the files the session’s packets point at. A .pcap rotates by size, not by session, so one file can hold traffic from inside and outside a session; a tag on the file would be the same fact said twice and wrong half of the time. Slicing a capture to the session is the evidence bundle’s job (§ The capture), and it reads the same derivation one step finer:

extension FlowStore {
    /// Per flow and per capture file (nil: never captured), how many packets the history holds.
    public func packetCounts(inAuditSession id: Int64) throws -> [SessionPacketCount]
    /// The session's packets whose bytes are in one file, by ascending offset.
    public func capturedPackets(inAuditSession id: Int64, fileSequence: UInt32) throws -> [SessionCapturedPacket]
}

capturedPackets is asked one file at a time: a file rotates at 64 MB, so what is held in memory is bounded by a file and not by how long the session ran. A packet without capture stores file 0 beside offset 0, and the offset is what says so — it is never counted in file 0.

Retention

Audit evidence is exempt from the storage limits until its session is deleted. The limits in Settings → Storage default to one week and 1 GB, and they are applied with nobody watching — by the extension every time the capture rotates. An audit session recorded on a Monday and exported the week after would have lost its traffic in between.

Three things this deliberately does not cover:

Store API

public enum AuditStoreError: Error, Sendable, Equatable {
    case emptyProjectName
    case duplicateAllowlistPattern(String)
    case projectNotFound(Int64)
    case sessionNotFound(Int64)
    case sessionAlreadyOpen(Int64)        // carries the session in the way
    case sessionAlreadyEnded(Int64)
    case dateBeforeSessionStart           // an end or a marker dated before the session began
    case emptyMarkerLabel
}

extension FlowStore {
    public func createAuditProject(_ draft: AuditProjectDraft, at date: Date) throws -> AuditProject
    public func auditProjects() throws -> [AuditProject]                       // newest first
    public func auditProject(id: Int64) throws -> AuditProject?
    public func updateAuditProject(id: Int64, with draft: AuditProjectDraft) throws -> AuditProject
    public func deleteAuditProject(id: Int64) throws

    public func startAuditSession(_ draft: AuditSessionDraft, at date: Date) throws -> AuditSession
    public func endAuditSession(id: Int64, at date: Date) throws -> AuditSession
    public func deleteAuditSession(id: Int64) throws                           // open or ended
    public func openAuditSession() throws -> AuditSession?
    public func auditSession(id: Int64) throws -> AuditSession?
    public func auditSessions(forProject projectID: Int64) throws -> [AuditSession]   // newest first

    public func addMarker(_ kind: SessionMarkerKind, toSession id: Int64, at date: Date) throws -> SessionMarker
    public func addMarkerToOpenSession(_ kind: SessionMarkerKind, at date: Date) throws -> OpenSessionMarkerOutcome
    public func auditRecording() throws -> AuditRecording?                     // the open session, named
    public func markers(forSession id: Int64) throws -> [SessionMarker]        // as they happened

    public func flows(inAuditSession id: Int64, limit: Int) throws -> [StoredFlow]    // oldest first
    public func flowCount(inAuditSession id: Int64) throws -> Int
    public func captureFileSequences(inAuditSession id: Int64) throws -> Set<UInt32>
    public func auditEvidenceFileSequences() throws -> Set<UInt32>             // of every session
}

Marking from outside the app

A marker has to be droppable without leaving the audited app: the assessor is in the middle of its onboarding, and coming back to TunnelVision to tap a button changes what is being measured. Three doors do it, and they are the same App Intent (PlaceAuditMarkerIntent, TunnelVision/Intents), so none of them can mark differently from the others:

Door Where it lives Runs in
Control Center control, Action Button, Lock Screen AuditControls (WidgetKit extension, iOS 18+) the extension or the app
App Shortcut (Shortcuts, Spotlight) and Siri the app (AuditShortcuts) the app, in the background

Which process runs the intent does not matter, by construction. The intent needs one thing — the shared database — and both the app and the widget extension have the App Group. It therefore does not go through AuditLibrary (which belongs to the app) but through OpenSessionMarking, which opens the store per call. AuditControls has no other entitlement: no Network Extension, no keychain.

The rules:

The control exists from iOS 18, and the app’s deployment target is 17. The extension target carries its own deployment target instead of availability checks on every type: on iOS 17 it is simply not offered, and the App Shortcut still is.

From the app

The app reaches all of this through AuditLibrary (TunnelVision/Services), an actor that opens the store per operation — like StorageManager, and for the same reasons — and classifies what it throws into AuditLibraryError: a rule the screen can do something about (AuditStoreError) or a history that did not answer. The screens are described in ../ux/audit.md.

What a session declares about its recording is not typed by anyone: AuditRecordingConditions reads the hardware model identifier (the simulated one on a Simulator, where uname reports the Mac’s architecture), the system version and the tool version, and derives InspectionConditions from the saved settings and the trust evaluation of the CA. Settings that cannot be read count as inspection off, which is what the extension does with them.

-TVSeedFixture seeds one project (AuditFixture, Debug-only) with a baseline and an audit session, both ended, each tagged with a quarter of the synthetic flows by the same path the extension takes — the session is open while its flows are written.

Findings

FindingsClassifier (Shared/Audit) turns the flows of a session into what they prove. It is pure — it reads neither the history nor the clock — and it does not decide verdicts: whether a finding breaks a requirement is the catalogue’s rule. It only states what the flows allow to be stated, and what could not be looked at comes back with its reason instead of being left out.

public enum FindingKind: String, Sendable, Hashable, Codable, CaseIterable {
    case weakTLSVersion                       // the raw value is what a catalogue refers to
    case cleartextTraffic
    case hostNotInAllowlist
    case unnamedFlow
    case pinningAbsent
    case pinningObserved
    case activityBeforeConsent
}

public enum FindingEvidence: Sendable, Hashable {
    case weakTLSVersion(TLSVersionObservation)
    case cleartextTraffic(CleartextProtocol)
    case hostNotInAllowlist(host: String)     // normalised
    case unnamedFlow(UnnamedFlowReason)
    case pinningAbsent(host: String)          // normalised
    case pinningObserved(host: String)        // normalised
    case activityBeforeConsent                // nothing: the instant belongs to each flow
    public var kind: FindingKind { get }
}

public struct Finding: Sendable, Hashable {
    public let evidence: FindingEvidence      // what is stated
    public let flowIDs: [Int64]               // the flows that prove it, as they happened; never empty
}

public struct CheckCoverage<Gap>: Sendable, Hashable {
    public let satisfiedFlowIDs: [Int64]              // looked at, nothing to report
    public let unassessed: [UnassessedFlows<Gap>]     // applicable, could not be looked at: why
    public let notApplicableFlowIDs: [Int64]          // not what this check looks at
}

public struct FindingsPolicy: Sendable, Hashable {
    public let minimumTLSVersion: TLSProtocolVersion
    public init?(minimumTLSVersion: TLSProtocolVersion)   // nil unless a published version
}

public struct SessionFindings: Sendable, Hashable {
    public let findings: [Finding]
    public let tlsVersion: CheckCoverage<TLSVersionGap>
    public let encryption: CheckCoverage<EncryptionGap>
    public let host: CheckCoverage<HostGap>
    public let pinning: CheckCoverage<PinningGap>     // satisfied is always empty
    public let consent: CheckCoverage<ConsentGap>
}

public enum FindingsClassifier {
    public static func classify(
        flows: [StoredFlow], project: AuditProject,
        session: AuditSession, markers: [SessionMarker],
        policy: FindingsPolicy
    ) -> SessionFindings
}

The TLS version of a flow

TLSVersionAssessment(of:minimum:) says one of four things about a flow: weak, acceptable (both with a TLSVersionObservation: the version and where it came from), notAssessed with a TLSVersionGap, or notApplicable.

What the flow carries Result
A ServerHello reading below the minimum weak, basis serverHello
A ServerHello reading at or above it acceptable — what the client offered does not qualify it
An upstream reading below the minimum weak, basis upstreamConnection
An upstream reading at or above it, and the app’s ClientHello listed its versions, all published and none below the minimum acceptable, basis upstreamConnection
An upstream reading at or above it, anything else notAssessed(.appNegotiationNotObserved) with the reason the offer does not settle it
A version that is not one of the five published notAssessed(.unrecognisedVersion)
An alert instead of a ServerHello notAssessed(.serverRefused)
QUIC v1 or v2 read from the server TLS 1.3, basis quic
QUIC read only from the client notAssessed(.quicVersionOnlyProposed)
A QUIC version not known to carry TLS 1.3 notAssessed(.unrecognisedQUICVersion)
No reading, but the status is not plaintext or a ClientHello was read notAssessed(.serverAnswerNotRead)
No reading and no sign of TLS or QUIC notApplicable

Whether a flow was encrypted

EncryptionAssessment(of:) is the rule behind cleartextTraffic. cleartext only comes from an observation: the flow’s stream was seen to open with a readable HTTP request (StreamOpening.httpRequest, relay-and-tls.md § How a stream opened). Neither the port nor TLSInspectionStatus is enough for anything — the status of a TCP flow is born from its port — so what was not seen is notAssessed with its reason, never “cleartext” and never “encrypted”.

What the flow carries Result
A stream that opened with an HTTP request, on any port, whatever the status cleartext(.http)
TCP whose stream opened with a TLS handshake, or any reading that only exists if TLS was negotiated (the client’s offer, the server’s answer, an inspection outcome) encrypted(.tls)
TCP whose stream opened with something else notAssessed(.unrecognisedOpening)
TCP with no opening read and none of the above — including 443, whose encrypted is the port’s notAssessed(.openingNotRead)
UDP with a QUIC version known to protect its packets, from either end encrypted(.quic)
UDP with another QUIC version notAssessed(.unrecognisedQUICVersion)
UDP with no QUIC reading — port 443 or not, and DNS on 53 notAssessed(.datagramsNotRead)
Anything that is neither TCP nor UDP notApplicable

Where a flow went

HostAssessment(of:project:) is the rule behind hostNotInAllowlist and unnamedFlow. It reads the flow’s FlowName (data-model.md § A flow has two names), never the sni column, and says one of four things: notInAllowlist(host:), unnamed with an UnnamedFlowReason, allowed, or notAssessed with a HostGap. There is no not applicable: every flow went somewhere, so an unnamed flow is not set aside from the check — it is a finding.

What the flow carries Result
No name, and no ClientHello was read unnamed(.noClientHelloRead)
No name, a ClientHello that announced none unnamed(.serverNameNotAnnounced)
No name, a ClientHello that announced none and carried Encrypted Client Hello unnamed(.encryptedClientHello)
A name, and the project’s allowlist is empty notAssessed(.allowlistEmpty)
An announced name (SNI) the allowlist covers allowed
An announced name it does not cover notInAllowlist
A name from DNS, and it and every other candidate of the address are covered allowed
A name from DNS, and neither it nor any other candidate is covered notInAllowlist, under the attributed name
A name from DNS whose candidates fall on both sides notAssessed(.candidatesDisagree(attributedNameAllowed:))

Whether the app pins

PinningAssessment(of:conditions:) is the rule behind pinningAbsent and pinningObserved. It bypasses nothing (ADR 0003): it reads the outcome the relay already recorded for an inspection attempt (relay-and-tls.md § TLS inspection). It says one of four things: absent(host:), observed(host:), notAssessed with a PinningGap, or notApplicable.

What the flow carries Result
No inspection outcome and no sign of TLS or QUIC — or an HTTP request seen in the clear, whatever the port’s status notApplicable
Anything else, in a session recorded with inspection off notAssessed(.inspectionOff)
Anything else, with inspection on and the CA not trusted notAssessed(.caNotTrusted)
inspected, with an announced name absent, under that name
notInspectable, with an announced name observed, under that name
inspected or notInspectable with no announced name notAssessed(.outcomeWithoutAnnouncedName)
A QUIC reading and no outcome notAssessed(.quicNotInspected)
TLS over TCP and no outcome notAssessed(.noInspectionOutcome)

ConsentAssessment(of:sessionKind:consent:) is the rule behind activityBeforeConsent. It compares the flow’s first packet (firstSeen) with the session’s consentGiven markers, which arrive as a ConsentInterval — the earliest and the latest of them, the same instant when there is one — and says beforeConsent, afterConsent, notAssessed with a ConsentGap, or notApplicable.

The session and the flow Result
A baseline session, with or without markers notApplicable
No consentGiven marker notAssessed(.noConsentMarker)
First packet before the earliest marker beforeConsent
First packet at the latest marker or after it afterConsent
First packet from the earliest marker on and before the latest notAssessed(.betweenConsentMarkers)

Release diff

ReleaseDiff (Shared/Audit) compares two audit sessions of the same project: the domains the later one contacted that the earlier one did not, the ones it stopped contacting, and what changed in the TLS of those that remain. Like the classifier it is pure and decides no verdict. It is built on SessionDomains, the inventory of one session, which compares nothing.

From the screen (2026-10-13). AuditLibrary.releaseDiff(between:and:inProject:) reads the project, its sessions and the flows of both in one visit to the store and builds the diff outside that visit: a ReleaseDiff.Refusal is a rule of the model, and inside it would come out classified as a history that did not answer. AuditViewModel keeps the comparison it has open together with the pair of sessions it belongs to — a comparison read for another pair is never drawn under this title — and what is said of it is ReleaseComparisonPresentation (../ux/audit.md § Comparing two versions). The bundle still exports one session and does not include a diff.

public struct DomainObservation: Sendable, Hashable {      // one domain in one session
    public let host: String                                // normalised, as a finding cites it
    public let flowIDs: [Int64]                            // flows that went to it
    public let candidateFlowIDs: [Int64]                   // flows that went to it or to another name
    public let tlsSightings: [TLSVersionSighting]          // versions read in flowIDs, with their source
    public let flowIDsWithoutTLSReading: [Int64]
    public var isSeen: Bool { get }                        // flowIDs is not empty
}

public struct SessionDomains: Sendable, Hashable {
    public let domains: [DomainObservation]                // order of first appearance
    public let unnamedFlowIDs: [Int64]
    public init(flows: [StoredFlow])
}

public enum AllowlistStanding: Sendable, Hashable {
    case listed(AllowlistEntry)                            // the first entry that covers it
    case unlisted
    case allowlistEmpty
}

public enum TLSNegotiator: Sendable, Hashable, CaseIterable { case app, tunnel }
public enum TLSFloorShift: Sendable, Hashable { case lowered, raised, held, notOrdered }
public enum TLSComparisonGap: Sendable, Hashable {
    case notReadInEarlierSession, notReadInLaterSession, notReadInEitherSession
}
public enum TLSVersionComparison: Sendable, Hashable {     // one domain, one negotiator
    case same([TLSProtocolVersion])
    case different(earlier: [TLSProtocolVersion], later: [TLSProtocolVersion], floor: TLSFloorShift)
    case notCompared(TLSComparisonGap)
}

public enum DomainPresence: Sendable, Hashable { case absent, candidateOnly, seen }
public enum DomainStatus: Sendable, Hashable {
    case new
    case gone
    case inBoth(app: TLSVersionComparison, tunnel: TLSVersionComparison)
    case undetermined(earlier: DomainPresence, later: DomainPresence)
}

public struct DomainComparison: Sendable, Hashable {
    public let host: String
    public let standing: AllowlistStanding
    public let earlier: DomainObservation?                 // nil: not in that session
    public let later: DomainObservation?
    public var status: DomainStatus { get }                // derived from the two observations
    public var hasTLSChange: Bool { get }
}

public struct ReleaseDiff: Sendable, Hashable {
    public enum Refusal: Error, Sendable, Hashable {
        case sameSession, differentProjects, notTheSessionsProject
        case baseline(sessionID: Int64)
        case stillOpen(sessionID: Int64)
    }
    public let earlier: AuditSession
    public let later: AuditSession
    public let domains: [DomainComparison]
    public let earlierUnnamedFlowIDs: [Int64]
    public let laterUnnamedFlowIDs: [Int64]
    public init(
        between first: AuditSession, flows firstFlows: [StoredFlow],
        and second: AuditSession, flows secondFlows: [StoredFlow],
        project: AuditProject
    ) throws
    public var newDomains: [DomainComparison] { get }
    public var goneDomains: [DomainComparison] { get }
    public var domainsInBoth: [DomainComparison] { get }
    public var undeterminedDomains: [DomainComparison] { get }
    public var unexpectedNewDomains: [DomainComparison] { get }   // new and unlisted: the headline
    public var domainsWithTLSChange: [DomainComparison] { get }
}

Which two sessions

The two sessions Result
The same session twice sameSession
Of different projects differentProjects
Of a project that is not the one given notTheSessionsProject
Either is a baseline baseline(sessionID:)
Either is still open stillOpen(sessionID:)
Anything else a diff

The reason given is the first that fails, in that order.

What the domain of a flow is

The flow’s FlowName, never the sni column, normalised with DomainPattern.normalised(host:) — the same text a hostNotInAllowlist finding cites, so the two can be matched.

What the flow carries Where it lands
An announced name (SNI) flowIDs of that domain; the resolved names are not consulted
A name from DNS with no other candidate flowIDs of that domain
A name from DNS whose address had other names candidateFlowIDs of every one of them, the attributed one included
No name unnamedFlowIDs

What is stated about a domain

Earlier session Later session Status
absent seen new
seen absent gone
seen seen inBoth, with its two TLS comparisons
candidate only, on either side anything undetermined, with how it stands on each side

TLS changes per domain

Only for a domain seen in both sessions, and only from the flows that went to it: a candidate flow’s version belongs to a connection whose domain is not known. The version of a flow is TLSVersionObservation(of:), the same reading TLSVersionAssessment judges, without a minimum.

Requirement catalogue

A catalogue is one version of a requirements document, written as a JSON resource: for each requirement, its identifier and title as the document gives them, and what this tool can say about it. RequirementCatalogue (Shared/Audit) reads one and refuses a miswritten one when it is loaded; SessionAssessment classifies a session with the catalogue’s thresholds and gives, per requirement, what the session shows.

public struct RequirementCatalogue: Sendable, Hashable {
    public static let formatVersion = 1
    public let identifier: String             // the resource name: AuditProject.catalogueVersion
    public let source: CatalogueSource        // document, title, version, date, sha256 of the PDF
    public let tlsSource: CatalogueSource     // where the TLS minimum comes from
    public let policy: FindingsPolicy
    public let requirements: [Requirement]    // in the catalogue's order, which is the document's
    public init(data: Data) throws            // RequirementCatalogueError
}

public struct Requirement: Sendable, Hashable, Identifiable {
    public let id: String                     // "O.Ntwk_1", as in the document
    public let aspect: RequirementAspect      // number and name of its Prüfaspekt
    public let title: String                  // the document's own short form, in its language
    public let testDepth: TestDepth           // CHECK / EXAMINE
    public let rule: RequirementRule
    public let toolCoverage: String?          // what the tool looks at for it, and what it does not
}

public enum RequirementRule: Sendable, Hashable {
    case outsideToolScope
    case contraryFindings([FindingKind])
    case contraryAndSupportingFindings(contrary: [FindingKind], supporting: [FindingKind])
    public var check: FindingsCheck? { get }
}

public enum RequirementVerdict: Sendable, Hashable {
    case contradicted
    case observedWithoutContradiction
    case notAssessed(NotAssessedReason)       // .outsideToolScope / .nothingObserved
}

public struct RequirementAssessment: Sendable, Hashable {
    public let requirement: Requirement
    public let verdict: RequirementVerdict
    public let contraryFindings: [Finding]
    public let supportingFindings: [Finding]
    public let coverage: CheckCoverageSummary?   // of the check behind the rule
}

public struct SessionAssessment: Sendable, Hashable {
    public let catalogue: RequirementCatalogue
    public let findings: SessionFindings
    public let requirements: [RequirementAssessment]
    public init(catalogue:flows:project:session:markers:)
}

public enum RequirementCatalogueLibrary {
    public static let defaultIdentifier = "tr-03161-1_3.0"
    public static func catalogue(for project: AuditProject) throws -> RequirementCatalogue
    public static func catalogue(identifier: String) throws -> RequirementCatalogue
}

The verdict is not the test result

TR-03161-1 gives a test four possible results — PASS, INCONCLUSIVE, FAIL, N/A (table 3) — and has the assessor justify each. None of them is this tool’s to give. What it gives is what the traffic showed:

What the session shows Verdict
The rule reads no finding kind notAssessed(.outsideToolScope)
A finding of a contrary kind contradicted, whatever else was seen
No contrary finding, and the rule’s check has satisfied flows or a supporting finding exists observedWithoutContradiction
Anything else notAssessed(.nothingObserved)

The format

{
  "formatVersion": 1,
  "identifier": "tr-03161-1_3.0",
  "source": { "document": "…", "title": "…", "version": "3.0", "date": "2024-03-25", "sha256": "…" },
  "tls": { "minimumVersion": "1.2", "source": { … } },
  "requirements": [
    { "id": "O.Ntwk_4",
      "aspect": { "number": 9, "name": "Netzwerkkommunikation" },
      "title": "Unterstützung von Zertifikats-Pinning.",
      "testDepth": "EXAMINE",
      "rule": "contraryAndSupportingFindings",
      "contraryKinds": ["pinningAbsent"],
      "supportingKinds": ["pinningObserved"],
      "toolCoverage": "…" }
  ]
}

Refused on loading, each with the requirement it is in: another formatVersion; an empty identifier, title or source field; a date that is not yyyy-MM-dd; a digest that is not 64 lower case hex digits; a TLS minimum other than "1.0"–"1.3"; no requirements, or one repeated; an unknown test depth, rule or finding kind (the raw values of FindingKind are format); a rule whose kinds do not fit it — missing, unexpected, repeated, or of several checks; and a rule that reads findings without a toolCoverage.

The catalogue that ships: tr-03161-1_3.0

   
Requirements BSI TR-03161-1, Anforderungen an Anwendungen im Gesundheitswesen – Teil 1: Mobile Anwendungen, version 3.0 of 2024-03-25
TLS minimum TLS 1.2, from BSI TR-02102-2 version 2026-01 of 2026-01-27, § 3.2: TLS 1.3 and 1.2 are recommended, 1.0, 1.1 and SSL are not

TR-03161-1 3.0 itself cites TR-02102-2 in its 2023-01 version; O.Ntwk_2 asks for the current state of the art, so the minimum is taken from the version in force, which recommends the same two.

Requirement Rule Reads
O.Purp_3 contraryFindings activityBeforeConsent
O.Purp_8 contraryFindings hostNotInAllowlist, unnamedFlow
O.Ntwk_1 contraryFindings cleartextTraffic
O.Ntwk_2 contraryFindings weakTLSVersion
O.Ntwk_4 contraryAndSupportingFindings pinningAbsent against, pinningObserved for
O.Ntwk_3, _5, _6, _7, _8 outsideToolScope —

Which catalogue a project is assessed against

AuditProject.catalogueVersion is a catalogue identifier or nil.

Evidence bundle

What a closed session is exported as: one folder, with the structured evidence as JSON and CSV, the capture, a report to be read and filed, and a manifest of digests. EvidenceBundle (Shared/Audit/Evidence) builds the documents and nothing else — it is pure: no history, no clock, no disk. The capture, which does not fit in memory, is written by EvidenceCaptureWriter (§ The capture); the folder and its archive are written by the app’s EvidenceExporter, which calls both (§ The exporter).

File What it is
session.json The project with its allowlist, the session with its release, device, inspection conditions and markers, and the two things a reader must be told: what is not inside, and how traffic is attributed
flows.json Every flow of the session with its names and their origin, every TLS reading with its source, and the findings it proves
flows.csv The same flows, one row each, flattened for a spreadsheet
findings.json The catalogue cited, a verdict per requirement, the findings, and how far each of the five checks got
capture.pcapng The packets of the session’s flows, each with a comment naming its flow and that flow’s findings
capture.json What the capture holds and, per flow and per reason, the packets it does not
report.pdf What the documents above say, composed on A4 for a person to read and file (§ The report, § Drawing the report)
manifest.json The SHA-256 of every other file
public struct EvidenceBundle: Sendable, Hashable {
    public let assessment: SessionAssessment          // what the documents were built from
    public let session: EvidenceSessionDocument
    public let flows: EvidenceFlowsDocument
    public let findings: EvidenceFindingsDocument

    public init(
        project: AuditProject, session: AuditSession, markers: [SessionMarker],
        flows: [StoredFlow], catalogue: RequirementCatalogue,
        exportedWith: String, exportedAt: Date
    ) throws                                          // EvidenceBundleError

    public func documentFiles() throws -> [EvidenceFile]      // the four documents, encoded
    public func manifest(
        of files: [EvidenceFile], adding extra: [EvidenceManifest.Entry]
    ) throws -> EvidenceManifest
}

public enum EvidenceBundleError: Error, Sendable, Hashable {
    case sessionOfAnotherProject(sessionProjectID: Int64, projectID: Int64)
    case sessionStillOpen
    case duplicateFlow(id: Int64)
}

public struct EvidenceManifest: Encodable, Sendable, Hashable {
    public struct Entry { name, byteCount, sha256 }   // init(_: EvidenceFile) hashes the bytes
    public init(sessionID: Int64, exportedAt: Date, entries: [Entry]) throws   // EvidenceManifestError
    public func file() throws -> EvidenceFile
    public static func sha256(of data: Data) -> String
    public static func hex(_ digest: SHA256.Digest) -> String
}

Finding identifiers

Each finding gets F1, F2… in the order of SessionFindings.findings — the order things happened. The identifier means something only inside one bundle: it is how a flow (findingIDs) and a requirement (contraryFindingIDs, supportingFindingIDs) point at a finding. Two exports of the same session give the same identifiers; the same host in two sessions does not.

What a flow carries

Key Notes
id, protocol, firstSeen, lastSeen, durationSeconds, bytesOut, bytesIn, packetCount protocol is tcp / udp / icmp / icmpv6 / other
peers The two endpoints as stored, not split into local and remote: the canonical 5-tuple does not know which is the device. The direction is in the byte counts
tlsStatus plaintext / encrypted / inspected / notInspectable. On TCP the first two come from the port; what was observed is in streamOpening and the TLS readings
name { text, origin }, origin sni or dns: the name the flow was judged by (FlowName)
sni, dnsName, dnsOtherNames The two fields as stored. A flow could have been any of dnsOtherNames
streamOpening tlsHandshake / httpRequest / unrecognised
clientTLS versionsForm is listed (the exact list) or upTo (then versions holds one entry, the ceiling); ALPN, how many identifiers were dropped, and the ECH mark
serverTLS answer negotiated — version, cipher suite, fromHelloRetryRequest, and source (serverHello / upstreamConnection) — or refused with the alert code
quic The version and the end it was read from; client is a proposed version
serverCertificate visibility: presented (with chain and chainIsComplete), notSent (with absence), encryptedInHandshake, replacedByInspection, noNegotiation, notRead
findingIDs The findings this flow proves

What findings.json says

Wording

EvidenceWording holds every sentence the bundle writes, in one place, because each is a limit that an earlier check set:

  Wording
cleartextTraffic An HTTP request was observed in the clear. — a request that was seen, not a port
pinningObserved A connection to this host refused the local CA’s certificate. — about the host, with the reason it may predate the session; never the app pins
pinningAbsent …accepted a certificate issued by the local CA: for this host, a user-installed root is trusted.
weakTLSVersion Names the catalogue’s minimum, and says so when the version is the one the server negotiated with the tunnel’s own connection
notAssessed(.outsideToolScope) Not assessed by this tool.

A statement carries no data: the host and the version are in their own fields. No sentence says passed, compliant or fulfilled, and a test holds that.

flows.csv

One row per flow, in the same order, UTF-8 with CRLF (RFC 4180); EvidenceFlowsCSV.headers is the header row. It is a flattened view and flows.json is the one that counts: of a certificate chain only the server’s own certificate is there, several values in one cell are separated by a space, and a TLS version is written without one (TLS1.2, or 0x7F1C when it has no name).

The capture

capture.pcapng is the device’s rotated .pcap files sliced to the packets of the session’s flows. EvidenceCaptureWriter writes it, and it is the one part of the bundle that reads the history and the disk.

public enum EvidenceCaptureWriter {
    public static func write(
        for bundle: EvidenceBundle, from store: FlowStore,
        captureDirectory: URL, into folder: URL
    ) async throws -> EvidenceCapture                 // EvidenceCaptureError, or the store's own
}

public struct EvidenceCapture: Sendable, Hashable {
    public let document: EvidenceCaptureDocument      // capture.json
    public let entry: EvidenceManifest.Entry          // capture.pcapng: size and SHA-256
    public func documentFile() throws -> EvidenceFile
}

public enum EvidenceCaptureError: Error, Sendable, Hashable {
    case flowNotInBundle(id: Int64)
    case destinationUnavailable(String)
    case writeFailed(String)
}

capture.json exists because a capture cannot say what it lacks — a packet without bytes is a packet that is not there:

Key Notes
file, fileFormat, linkType, snaplen capture.pcapng, pcapng, 101; snaplen absent when no source file was read
packets recorded (what the history holds), written, and withoutBytes by reason. recorded is always the sum of the rest: a packet is written or has a reason not to be
withoutBytes.notCaptured Never written to a capture file: capture was off, or its writer failed
withoutBytes.captureFileMissing Its file is no longer on the device. The sweep never takes evidence (§ Retention); deleting by hand does
withoutBytes.recordUnreadable Its file is there but the record could not be read back: not a capture of ours, or cut short there
writtenOutsideSession beforeStart, afterEnd: written packets stamped outside the session
sourceFiles By sequence: read / missing / unreadable, how many of the session’s packets pointed at it and how many were written
flows Every flow of flows.json, in its order, with its own packets — also those with none
contents, packetComments The two notes, from EvidenceWording

The manifest

manifest.json lists every other file by name with its size and SHA-256 in lower case hex (what shasum -a 256 prints), sorted by name, so that the evidence can be shown to be unaltered after export. It does not list itself.

The exporter

EvidenceExporter (TunnelVision/Services) is the app’s half: it reads a closed session out of the history, has the two pieces above write its folder, and compresses it.

public actor EvidenceExporter {
    public init(appGroupID: String = AppGroup.identifier)
    public func export(
        sessionID: Int64, exportedWith: String, now: Date = Date()
    ) async throws -> EvidenceExportResult             // EvidenceExportError
}

public struct EvidenceExportResult: Sendable, Equatable {
    public let url: URL                                // the .zip
    public let byteCount: UInt64
    public let fileNames: [String]                     // the eight files, by name
    public let flowCount: Int
    public let findingCount: Int
    public let capture: EvidenceCaptureDocument        // capture.json, as written
}

public enum EvidenceExportError: Error, Sendable, Equatable {
    case sessionNotFound
    case sessionStillOpen
    case catalogueNotBundled(identifier: String)
    case catalogueUnusable(identifier: String)
    case historyUnreadable(HistoryError)
    case historyChangedWhileExporting
    case captureDirectoryUnavailable(String)
    case writeFailed(String)
}

From the screen. AppEnvironment builds one EvidenceExporter and hands it to AuditViewModel as a closure, (sessionID, instant) -> EvidenceExportResult, so that every failure can be scripted in a test; the tool’s version is read there, with AuditRecordingConditions.environment(), the same way a session reads it when it starts. AuditViewModel.exportEvidence(ofSession:) passes the instant of the tap, holds isWorking for as long as the bundle is being written — a second export, or any other write, is ignored meanwhile — and leaves either pendingEvidence, an EvidenceExportSummary for the sheet, or a notice. A failure does not take away a summary already on screen. When the exporter finds the session gone or still open, the view model re-reads: what is drawn is no longer what there is. What the sheet says is decided in AuditPresentation (EvidenceExportPresentation.swift) and described in ../ux/audit.md § Exporting a session. EvidenceCaptureStanding itself lives in Shared/Audit/Evidence: the report reads the same three cases (§ The report).

public enum EvidenceCaptureStanding: Sendable, Equatable {
    case nothingRecorded
    case complete(packets: Int)
    case incomplete(written: Int, recorded: Int, missing: [EvidenceMissingPackets])
    public init(_ tally: EvidencePacketTally)          // only the reasons that have packets
}

extension AuditPresentation {
    public static func evidenceExportPrepared(_ result: EvidenceExportResult) -> EvidenceExportSummary
    public static func evidenceCapture(_ document: EvidenceCaptureDocument) -> EvidenceCaptureDisplay
    public static func evidenceExportFailed(_ error: EvidenceExportError) -> AuditNotice
}

Opened outside the tests (2026-10-09, before the bundle carried report.pdf). The session seeded by -TVSeedFixture was exported from the app on a Simulator and the archive taken out of its container: unzip -t reports no errors in the seven files; each of the six files the manifest lists has the size and the SHA-256 it states (hashlib, not our code); the capture holds exactly the 4,842 packet blocks capture.json counts as written, of 4,895 recorded, with 53 never captured and one from before the session; and tcpdump -k IDC -r prints each packet’s direction and comment (out, flow=7 findings=F1,F2).

Measured on a Simulator (EvidenceExporterMeasurementTests, which runs only when asked: it seeds hundreds of megabytes). A session of 400 000 packets and 229 MB of capture in four source files:

Flows Time Memory footprint, before → peak
20 000 4.8 s 38 MB → 143 MB
2 000 3.2 s 38 MB → 85 MB

Memory follows the number of flows — about 3 kB each, the documents held whole in memory (§ Evidence bundle) — over some 45 MB that go with the packets. While it runs, the disk holds the folder and the archive at once: up to twice the capture. The archive sizes of that run say nothing: the seeded payload is one repeated byte.

The report

report.pdf is the piece of the bundle that is read by a person and filed. EvidenceReport (Shared/Audit/Evidence) is what it says, before anything is drawn: which sections, which rows, which sentence in each place. It is pure; composing it into pages and drawing it is § Drawing the report.

public struct EvidenceReport: Sendable, Hashable {
    public let title: String
    public let subtitle: String                        // project · release or "baseline" · session id
    public let sections: [EvidenceReportSection]       // always all eight, in order

    public init(
        bundle: EvidenceBundle, capture: EvidenceCaptureDocument, limits: EvidenceReportLimits
    ) throws                                           // EvidenceReportError
}

public enum EvidenceReportSectionKind: String, CaseIterable {
    case session, method, catalogue, requirements, findings, checks, tlsReadings, capture
}

public enum EvidenceReportBlock: Sendable, Hashable {
    case heading(String)
    case paragraph(String)                             // the report's own text
    case note(String)                                  // quoted from another document of the bundle
    case facts([EvidenceReportFact])                   // label, value
    case table(EvidenceReportTable)                    // columns, rows
}

public struct EvidenceReportFact: Sendable, Hashable {
    public let label: String
    public let value: String
    public let staysWithNext: Bool                     // no page break between this fact and the next
}

public struct EvidenceReportLimits: Sendable, Hashable {
    public init?(flowIDsPerFinding: Int, tlsReadingRows: Int)   // nil unless both are positive
    public static let bundled: EvidenceReportLimits             // 12 and 150: the report in the bundle
}

public enum EvidenceReportError: Error, Sendable, Hashable {
    case captureOfAnotherSession(captureSessionID: Int64, sessionID: Int64)
}
Section What it says
session Project, release, instants, device, both tool versions, the inspection conditions and — always, in a sentence — whether anything about pinning can be read from this session; the markers and the allowlist as tables, or the sentence that says there is none and what follows from that
method The four notes of the bundle, quoted: attribution first (ADR 0008)
catalogue The identifier and, for each of the two source documents, title, version, date and the SHA-256 of the PDF it was checked against; the TLS minimum
requirements A table of every requirement with its verdict statement, then each one: aspect, test depth, verdict, its toolCoverage right after the verdict, the findings against it and in its support, and how many connections the check behind it looked at, could not assess, and does not apply to
findings Findings that state the same sentence go under it once, a row each: identifier, what it is stated about (the host, or the version with where it was read), how many connections, and their flow ids
checks For each of the five: connections behind a finding, looked at with nothing to report, not assessed, not applicable — they add up to the session’s flows — and a row per reason it could not look, as a sentence
tlsReadings One row per host and reading: the host and where its name comes from, the version, where it was read, how many connections
capture Which of the three cases of EvidenceCaptureStanding holds, the counts that have packets under the keys of capture.json, and the capture’s two notes

Drawing the report

Three pieces, in the app (UIGraphicsPDFRenderer is UIKit), each knowing nothing of audits:

// TunnelVision/Models — pure: no drawing, no font
public struct EvidenceReportLayout: Sendable, Hashable {
    public let geometry: EvidenceReportPageGeometry
    public let bodyFrame: CGRect                       // inside the margins, above the footer
    public let pages: [EvidenceReportPage]             // number, body marks, footer marks
    public init(
        report: EvidenceReport, geometry: EvidenceReportPageGeometry,
        measuring: some EvidenceReportTextMeasuring
    )
}

public enum EvidenceReportMark: Sendable, Hashable {
    case text(String, style: EvidenceReportTextStyle, origin: CGPoint)   // one line, already broken
    case fill(CGRect, EvidenceReportFill)                                // rule, quoteBar, headerBand
}

public protocol EvidenceReportTextMeasuring {
    func lineHeight(of style: EvidenceReportTextStyle) -> CGFloat
    func width(of text: String, style: EvidenceReportTextStyle) -> CGFloat
    func lines(of text: String, style: EvidenceReportTextStyle, width: CGFloat) -> [String]
}

public enum EvidenceReportColumnWidths {
    public static func shares(natural: [CGFloat], into available: CGFloat) -> [CGFloat]
    public static func filling(natural: [CGFloat], into available: CGFloat) -> [CGFloat]
}

// TunnelVision/Services — UIKit and CoreText
public struct EvidenceReportTypography: EvidenceReportTextMeasuring, Sendable
public enum EvidenceReportPDF {
    public static func data(
        of layout: EvidenceReportLayout, typography: EvidenceReportTypography,
        title: String, creator: String
    ) -> Data
}

EvidenceReportLayout decides where everything goes — which is everything a page break can spoil — and hands over lines already broken and placed; EvidenceReportPDF puts each mark where it says and decides nothing. The layout takes whoever measures text as a parameter, so pagination is asserted with a measurer in which every character is five points wide, over fifty page heights, rather than by looking at a PDF.

Looked at (2026-10-10), not only opened: both seeded sessions, every page. Looking is what found the label column changing width from one list to the next, a quoted note leaving a single line overleaf, and tables with the same headings taking different shapes. The pages can be had again with one command (EvidenceReportSampleTests, which runs only when asked).

Measured, with the report inside (EvidenceExporterMeasurementTests, same session as above): 20 000 flows, 2 000 of them a finding each — a report of some hundred pages — export in 4.95 s against 4.8 s, with a peak of 150 MB against 143 MB.

Tests