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).
| 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.
/// 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).
*.example.com does not match example.com. It is the certificate-wildcard convention, and the
opposite would make allowing a third party’s subdomains silently allow its apex too. It does match
at any depth (a.b.example.com), and the comparison is on labels, not on text: notexample.com
does not match.xn--
(packet-parsing.md § Above L4); a Unicode pattern would never match
anything, which in an allowlist is a silent hole. It is rejected so it gets written in punycode.supportsPinningEvidence is the only place the pinning precondition is stated. Without
inspection there is no handshake against the local CA; with the CA untrusted every app rejects it,
pinned or not. In both cases notInspectable says nothing about the app.v6audit_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.
upsertFlow tags with “the open session”;
with two, that phrase means nothing. Two processes write this database, so the rule is a partial
unique index and not only a check in code. The check in code exists too, to report which session
is in the way.SET NULL). The flow happened
regardless.clearAll() leaves projects and sessions in place. It empties the traffic history, not the work
of setting up an audit; the sessions simply have no flows left.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.
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.
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.
FlowStore.prune(before:) skips every flow that carries an audit session, open or
ended.auditEvidenceFileSequences() returns the files that hold bytes of any session;
RetentionPlanner never puts them in a plan, by age or by size
(app-services.md § Storage manager). The whole file is kept, including the
traffic from outside the session it may also hold — a .pcap cannot be trimmed in place.deleteAuditSession) or its project. The flows
lose their tag, become ordinary history, and the next cleanup treats them and their files like
anything else. Ending a session does not: an ended session is exactly the one waiting to be exported.sizeCapHeldByEvidence) and its own sentence on both screens that report it. The limit stays unmet;
evidence is not sacrificed to it.Three things this deliberately does not cover:
clearAll() keeps projects and sessions, and empties them). The
exemption is from the automatic sweep, not from the owner of the device.
But neither does it happen unsaid (2026-10-05): both confirmations name the evidence before it
goes. Deleting one capture asks the history whether it holds packets of an audit session
(CaptureEvidenceStanding: held / none / unknown) and, when it does, opens with that — the
session keeps its connections, its packets can no longer be exported; when the history cannot be
read it says it cannot tell, rather than implying there is nothing to lose. Delete everything
counts the connections that belong to any session (FlowStore.auditFlowCount, carried in
StorageUsage) and says that the sessions stay with nothing left to export. They are the only two
places where evidence can be lost, since the sweep never takes it.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
}
updateAuditProject replaces the allowlist rather than patching it: its order is part of what is
stored, and a draft already carries the list as it should end up. The rules are the ones of creation.
Sessions are untouched, so an old session is read against today’s allowlist — which is what makes two
releases comparable.deleteAuditSession takes the markers with it and leaves the flows untagged, like deleting a project
does. Deleting the open session is also what stops the tagging.auditEvidenceFileSequences() is one query and not a union of captureFileSequences(inAuditSession:)
because the sweep needs one answer taken at one instant.
flows(inAuditSession:) is ordered by first_seen ascending, the opposite of recentFlows.
The Timeline is read from now backwards; evidence is read as it happened.AuditStoreError; an unreadable row throws StoreError.corruptRow.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:
addMarkerToOpenSession reads the open session and writes the marker in the same write. The app
can end the session from another process between the two; done in two steps, the marker would land
in a session that had just ended or fail with an error that does not say what happened..noOpenSession, and the intent ends
with an error whose sentence says nothing was placed. A marker that was not placed and that the
assessor believes placed is worse than a failure.AuditMarkerIntentCopy.reading maps the three
reports — placed, no open session, not placed (the database did not answer, or refused the marker)
— to one confirmation and two errors. The confirmation names the marker, the time to the second
and the project: that is what lets it be matched against what was happening in the audited app.perform(), before the database
is opened, like the Mark now buttons take theirs at the tap.AuditMarkerOption). The free-form one needs typing,
which cannot be done mid-onboarding without the instant ceasing to be the gesture’s; it stays on the
session screen. App Intents extracts its copy at compile time and accepts only literals, so the
three names are written a second time there; a test holds them equal to the session screen’s, and
fails if a fixed marker is added without its option.AuditMarkerControlState). “No session” and “could not read” are told
apart: the second cannot promise that nothing is recording. The control is configurable — consent
by default, since that is the marker behind activity before consent — so an assessor can add one
per marker.AuditControlRefresh), and re-reads itself when it comes back to the foreground
(AuditViewModel.resume): a marker placed from outside is written without passing through the view
model, and an assessor who returns to the session screen and does not see it would read that as
not placed.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.
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.
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
}
hostNotInAllowlist and of the two pinning kinds, which is the statement itself. Packets are
reached through the flow, and from there to the file and offset of their bytes.consentGiven is read; a marker of another
session is ignored.pinningObserved),
because the report has to name the hosts it was seen on, so that check never has satisfied
flows and a requirement about pinning reads the two kinds.FindingsPolicy and carries no
threshold of its own. A minimum that is not a published version is refused when the policy is
built, so a miswritten catalogue is found on loading it.FindingKind only has the kinds the classifier produces. A kind nothing can raise would be a
line of the report that is always clean without anybody having looked.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 |
clientTLS too, and the QUIC rows above ignore it on purpose: the version of QUIC is what says
TLS 1.3, and a client may not offer anything older inside QUIC (RFC 9001 § 4.2), so the offer
could neither weaken an acceptable nor settle a version that was only proposed — what is
missing there is the server’s word, not the client’s.relay-and-tls.md § What an inspected flow’s server chose). A weak one is
still a finding — a server that gives a modern client no more than that gave the app no more
either — but a good one only says what the server accepts. What the app would have negotiated
comes from its own offer, and only an exact list with nothing below the minimum rules a weaker
version out: a legacy_version ceiling does not say where the floor is, an offer with Encrypted
Client Hello may not be the real one, and an empty list or one with unpublished values cannot be
ordered. Each of those is its own ClientOfferGap, so the report can say which.TLSProtocolVersion.isPublished, SSL 3.0 to TLS 1.3). By
raw value a TLS 1.3 draft (0x7F..) is greater than TLS 1.3; it is neither weak nor acceptable.serverTLS == nil is never read as “not TLS”. With any sign of TLS it is a reading that is
missing. And notApplicable is not a statement that the flow went in the clear: on TCP the
status is encrypted for port 443 and plaintext for every other port because of the port
(FlowTable.initialTLSStatus), and the ServerHello is only read on 443. TLS
on any other port is recognised (it raises the status, so it lands in serverAnswerNotRead), but
its version is not read.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 |
v14 has no openings, so its plain TCP flows are
openingNotRead: no cleartext finding and no satisfied flow either. That is correct — nobody
looked — and it is why the coverage exists.cleartextTraffic finding. Today it falls in datagramsNotRead
with every other datagram; a finding of its own needs to know which end of the flow is the
device, which a stored flow does not say.unrecognisedOpening:
the report can list those flows, and cannot call them clear.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:)) |
packet-parsing.md § Names from DNS). So while the candidates do not all
say the same, nothing is stated about the flow: it is neither a finding nor a satisfied flow, and
the gap says on which side the attributed name falls. When every candidate is outside, the flow
went outside whichever it was, and the finding is filed under the attributed name; the others are
on the flow.DomainPattern.normalised(host:): lower case, no trailing dot), so every connection to the same
unlisted host is one finding however the name was written and wherever it came from. The origin
of the name — SNI or DNS — is read from each flow.serverNameNotAnnounced is what such a connection looks like, not proof of one.packet-parsing.md § Naming a QUIC flow): an announced name is judged
like any SNI, an offer without a name is serverNameNotAnnounced or encryptedClientHello, and
noClientHelloRead on a QUIC flow is a connection whose start the tunnel did not see, or a
ClientHello it could not read or did not try to — never “it was QUIC”. Nothing in this type
changed for it; QUICNamedFlowAssessmentTests holds what each check says of such a flow.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) |
pinningAbsent: a connection completed its handshake against a
certificate issued by the local CA, so for that host the app trusts a root the user installed.
pinningObserved: a connection to that host refused that certificate. Neither says why —
an app that refuses may pin, or may simply not accept user-installed roots.InspectionConditions.supportsPinningEvidence
and nowhere else. With the CA untrusted every app refuses, so a refusal is not reported; and
an inspected flow in a session declared without inspection is not reported either — the
conditions the session was recorded under are then not the ones it declares, and the report
does not pick which to believe. The reason given is the first that fails, so a QUIC flow in a
session without inspection says inspectionOff.encrypted alone is not acceptance. A flow with no outcome was not a candidate (off 443,
no announced name), failed for a reason that was not the app, or never closed cleanly. It is
unassessed, never counted for either kind.pinningObserved is a statement about the host, not about each connection. After the first
refusal the relay does not try that host again for as long as the tunnel runs
(PinnedHostMemory) and marks the following flows notInspectable without testing them. So
the finding says a connection to the host refused — at least once since the tunnel started,
which may be before the audit session opened — and it cannot show a second client of the same
host that would have accepted.hostNotInAllowlist’s. The certificate the
app accepted or refused was issued for the SNI, so a name deduced from DNS does not stand in
for it.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) |
consentGiven markers do not tell
which one counts — a second tap, a second consent dialog, consent withdrawn and given again —
and choosing would be deciding it for the assessor. Before all of them is a finding, after all
of them is satisfied, and in between nothing is stated.lastSeen). So is
a flow older than the session itself that carried traffic during it (§ Which session a flow
belongs to).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 }
}
| 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.
startedAt; the greater id if they
coincide). The two are given in either order and the result is the same. Version and build are
free text and nothing orders 2.4.0 (118) against 2.4.0-rc1 (2024.3); which of two sessions was
recorded first is known. So an older release recorded afterwards is the later side — both
releases are on the result for the report to print.SessionDomains is the part it would share.StoredFlow does not say which session it
belongs to and flows(inAuditSession:limit:) takes a limit: a truncated list gives domains as
gone or misses new ones, and nothing here can tell.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 |
| 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 |
unexpectedNewDomains). With an empty allowlist there is
none — nobody has said what is expected (§ Where a flow went) — and an undetermined domain is
never in it.gone means no flow of the later session carried
that name; an unnamed flow of that session may have gone exactly there, and a new domain may
have been reached without a name before. That is why the unnamed flows of each side travel with
the diff: the report prints their count beside the lists.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.
relay-and-tls.md § What an inspected flow’s server
chose). Each has its own comparison. A session recorded with inspection against one recorded
without it gives two readings with no counterpart — notCompared on both — not a TLS change.TLSFloorShift), since that is what a
minimum-version requirement looks at: one weaker connection in the later session is lowered.
When the sets differ and the weakest is the same it is held; with any unpublished value on
either side it is notOrdered (§ The TLS version of a flow).notCompared with which side;
the flows are in flowIDsWithoutTLSReading. A domain that went from TLS to cleartext HTTP is
therefore not compared here — it is the later session’s cleartextTraffic finding.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
}
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) |
observedWithoutContradiction is bounded by the
requirement’s toolCoverage, which the report prints beside it: nothing contradicting O.Ntwk_1
means no HTTP request was seen in the clear, not that mutual authentication was shown.CheckCoverage.satisfiedFlowIDs); a session where nothing was
readable is not assessed. A consent requirement in a session nobody marked, or in a baseline,
lands there too, and the coverage says how many flows were unassessed or not applicable.pinningAbsent contradicts, pinningObserved supports. Supporting findings are returned
even when the verdict is contradicted — a host that refused the local CA still did.FindingKind.check; that map is code, not JSON), and a rule whose kinds span two is refused:
there would be no single coverage to say how much was looked at.SessionAssessment
classifies with the catalogue’s own policy. Requirement.assess(_:) is public for the tests
and for a caller that already holds the findings, and says what it assumes.ReleaseDiff produces no Finding. It is a section of the report.{
"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.
"1.2"), not as its wire value: a
catalogue is corrected by someone who assesses against the guideline.toolCoverage is the catalogue’s text, not the document’s, and it is mandatory wherever a
verdict other than not assessed is possible.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 |
— |
Tools/Catalogue/verify.py checks all three against the PDF, and that the PDF is the one the
catalogue cites by its SHA-256. The PDF is not in the repository.O.Purp_3), and a connection to an unlisted host is not shown to carry
sensitive data (O.Purp_8). Each toolCoverage says so.AuditProject.catalogueVersion is a catalogue identifier or nil.
nil is the default catalogue, RequirementCatalogueLibrary.defaultIdentifier. The
catalogue used travels in the SessionAssessment, so the report names it either way.notBundled, never replaced by another:
the report would cite a version of the document the project does not claim to be assessed
against.Shared framework’s bundle (Shared/Audit/Requirements/*.json),
never from the network. A resource whose identifier is not its file name is refused.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
}
SessionAssessment
itself from the flows it is given, so the flows listed in flows.json and the flows the findings
of findings.json point at cannot come from two lists. The assessment is kept, for the report.endedAt is not optional
in session.json. A session of another project is refused too: it would be judged against
somebody else’s allowlist. Markers of another session are ignored, in the classification and in
the document.kind baseline and no release.EvidenceBundleFormat.version, 1), written in
every document beside its own format identifier (tunnelvision.evidence.session, .flows,
.findings, .manifest). The documents are read together; one of them at another version is
not a bundle.exportedAt is given, not read from a clock.null. The one exception by design is serverCertificate, which is always present
and says why when there is no chain (§ below).session.json says so. Nor is the release diff: a bundle is one session.session.environment.toolVersion recorded the session; exportedWith
wrote the bundle. They can differ, and the wording of a finding is the exporter’s.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.
| 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 |
{ wireValue, name }, the name absent when the value is not one
of the five published versions. A cipher suite and a QUIC version are { wireValue, hex }
(0xC02F, 0x00000001) with no name: a name table is presentation, and the code is what was sent.flows.json says so in its own contents
note, together with what upstreamConnection means.findings.json saysverdicts, a note at the top: a verdict states what the traffic showed within the
requirement’s toolCoverage; it is not a test result, and none says a requirement is met.catalogue: the identifier, the two source documents with version, date and the SHA-256 of
the PDF the catalogue was verified against, and the TLS minimum it classified with.requirements, one per requirement of the catalogue in its order: identifier, aspect, title
and test depth as the document gives them; rule; verdict — contradicted,
observedWithoutContradiction or notAssessed, the last with notAssessedReason
(outsideToolScope / nothingObserved); verdictStatement; toolCoverage, present
whenever the rule reads findings; the finding identifiers against and for; and check, the
name of the check whose coverage backs the rule.findings: id, kind (the raw value of FindingKind), statement, the flows, and what
is stated in a field of its own — host, tlsVersion (version, basis, and
fromHelloRetryRequest or quicVersion), cleartextProtocol, unnamedReason.checks, always all five: satisfiedFlowIDs, notApplicableFlowIDs, and unassessed —
a reason with the flows it holds and the details of that reason (alert, tlsVersion,
upstreamVersion, clientOffer, clientOfferCeiling, quicVersion,
attributedNameAllowed). A requirement names its check instead of repeating its coverage.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.csvOne 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).
=, +, -, @, a tab or a carriage return is written with an apostrophe in front. The
untouched value is in flows.json.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)
}
pcap.md); the exported file is pcapng because only pcapng carries options per
packet, and the bundle needs two. PcapngFormat (Shared/Capture) writes the three blocks it
takes: one section header naming the tool that exported, one interface (LINKTYPE_RAW,
microsecond timestamps) and one enhanced packet block per packet. Wireshark and tcpdump open
it as they open a .pcap.flow=12, or
flow=12 findings=F1,F3 (EvidenceCaptureComment): the flow as listed in flows.json and the
findings of findings.json that flow is evidence of. Wireshark shows it and filters on it —
frame.comment matches "flow=12( |$)" for one flow, frame.comment matches "F3(,|$)" for the
packets behind one finding. It is not an index of offsets beside the capture because an offset
only means something in a file nobody has saved again, and a comment travels with its packet.epb_flags: inbound or outbound, as seen from the
device). It is the one place the bundle says which way a packet went: flows.json has two
peers and does not know which is the device.capture.json counts
them (writtenOutsideSession), comparing at the microsecond a record is stamped with.snaplen is the largest among the files that were read — each file declares
its own, since changing the setting rotates — and 0 (no limit, in pcapng) when none was.
A packet cut by its file’s snaplen keeps both lengths.capture.json.flowNotInBundle), before any
file is touched. It is the one thing EvidenceBundle could not check — that it was given every
flow — seen from the side where it shows: packets in the capture that flows.json cannot name.Entry. On any failure the partial file is removed —
half a capture under the name of a whole one is worse than none.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 |
contents says what a reader must not assume: the packets are written as they crossed the
tunnel, so what was sent in the clear is readable in the capture. That is the wire, not
decrypted content — which stays out of the bundle (ADR 0007).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.
manifest(of:adding:) takes the encoded files
rather than encoding again.SHA256 fed in pieces, EvidenceManifest.hex) and hands in an
Entry.., .., or with a /); a
name twice; manifest.json itself; a digest that is not 64 lower case hex digits.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)
}
capture.json is encoded, report.pdf is drawn from those very
documents, and manifest.json goes last, over the very bytes that were written. Then the
folder is compressed.tunnelvision-evidence-session-<id>-<yyyyMMdd-HHmmss>.zip
(the instant in UTC) holds a folder of the same name with the eight files. The name carries the
session’s identifier and neither the project’s name nor the release: both are free text typed by
the assessor, and which session it is, is what session.json says.NSFileCoordinator’s .forUploading — no dependency. The
archive it hands over exists only inside its block, so it is moved out before returning.flows(inAuditSession:limit:) is asked for all of them: a bundle
lists every flow of its session or is not evidence of it, and a closed session gains no more.
Should the capture still find packets of a flow that was not read, the export stops as
historyChangedWhileExporting — repeating it is the remedy, which is why it is its own case.catalogueNotBundled).capture.json. How many packets have no bytes, and why, is what an
assessor does not see by opening the archive, so whoever offers it to be shared has it in hand.exportedWith, written as
AuditRecordingConditions.toolVersion writes it) and the instant: the exporter reads no
clock of its own beyond the default argument.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.
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)
}
EvidenceBundle already built and from its capture.json, so the report cannot say something
the files beside it do not. The one thing read from EvidenceBundle.assessment rather than from a
document is why a check could not look at a flow: there the reason still has its type, and every
case gets a sentence without a default.note is text another document of
the bundle already carries, quoted; a paragraph is the report’s own.EvidenceWording too (EvidenceReportWording.swift).serverHello, upstreamConnection, consentGiven,
notCaptured, the finding kinds, the check names in parentheses — so that a line of the report
can be looked up in the file it summarises.capture.json of another session is refused.| 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 |
checks, which is where a reader sees whether anything could be looked at.limits.flowIDsPerFinding flow ids and the TLS table up to limits.tlsReadingRows rows; past
that it states how many are not printed and which file has them all. The limits have no default:
one finding of a session of twenty thousand flows cites every one of them. The report in the
bundle uses EvidenceReportLimits.bundled, chosen with the page in front (§ Drawing the report).staysWithNext whenever a toolCoverage follows it: a
verdict at the foot of a page with its coverage overleaf reads as the whole requirement.flows.json holds for the flow, the server’s answer before the QUIC
version, as TLSVersionObservation does. A QUIC version is printed as a QUIC version — what it
implies about TLS is the tlsVersion check’s to say — and one read from the client is marked as
such. A flow the server refused with an alert, and one with no reading, are counted, not listed.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.
staysWithNext from the next — and when the two do not fit on one
page together, being together wins over being whole; leave one line of a text alone on either
side. A page does not open with the space that separated what came before.EvidenceReportColumnWidths. Each column gets what its widest
cell asks for if that fits, and those that do not fit share what is left evenly, so a count or an
identifier is never broken to make room for a sentence and no wide column gets less than an even
share. A table takes the whole width; what is left over goes to the column that asked for most,
so the narrow columns of two tables with the same headings — one per kind of finding — measure
the same whether or not there was room to spare.EvidenceWording: a loose sheet of a file says how many it is one of.EvidenceReportLimits.bundled). Looked at on the session -TVSeedFixture seeds (63 flows, 62
findings: nine pages) and on one of 2 000 flows over the same hosts (fourteen): at 24 ids a
finding took five lines and a page held nine findings, for a list nobody checks on paper and that
was still not whole. 150 rows are five pages at most. Markers, the allowlist, requirements and
findings are not bounded.report.pdf is not the same bytes twice. Every other file of the bundle is; a PDF written by
the system carries the instant it was written (/CreationDate, /ModDate) and an identifier of
its own (/ID), which no public key of the renderer sets — 62 bytes differ between two renderings
of one layout, and nothing else. The manifest covers the bytes that were written all the same, so
what was exported can still be shown unaltered; what does not hold is that exporting a session
twice gives the same report.pdf, and therefore the same manifest.json. The same session does
give the same layout, and the same text.manifest.json lists what a bundle holds.
session.json’s note of what is inside is unchanged too: the report adds no kind of data, it is
the documents drawn.TunnelVision <version>, the exporting tool.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.
EvidenceExporterTests, against a real FlowStore, files written by a real PcapWriter and a
zip reader that shares no code with anything: the archive holds the eight files in one folder;
the manifest inside it is the size and digest of every other file inside it; the documents are
the session’s, with its markers and the exporting tool; the capture holds the session’s packets;
session.json names the capture beside it; the result carries what the capture lacks, as
capture.json in the archive says it; a session with no traffic still gives a whole bundle;
every flow is listed however many there are; only the archive is left on disk; an export takes
the previous one, and an interrupted one, and nothing else; two exports of a session at the same
instant carry the same six documents byte for byte, a report that reads the same, and manifests
that agree on everything but the report; the report in the archive is a PDF of this session, on
A4, naming the tool that exported it, and counts the packets capture.json counts; an open session, a session that is gone, a catalogue that is not
bundled, a history that does not open, a capture directory that does not resolve and a
destination that cannot be created, each as its own case; the names.EvidenceReportTests: a capture.json of another session and limits that would print nothing
are refused; every section is there, in order, for a session with no flows; every row has its
header’s columns; the same session gives the same report; the subtitle of an audit and of a
baseline; the session’s conditions, and the sentence about pinning in both cases; markers and
allowlist as tables, and their absence said; the method section is the bundle’s four notes; the
catalogue with both documents; every requirement with the verdict findings.json writes; the
toolCoverage right after the verdict, and none on a requirement outside the tool’s scope; a
requirement’s findings and its check’s counts; findings grouped under their statement; a weak
version with where it was read; a list past its limit; no findings is not “nothing wrong”; every
connection counted once in each check, and the reasons adding up to not assessed; pinning
without a count of connections looked at; each reason as a sentence, and all twenty-four of them
distinct; no table of reasons where there is none; the TLS table by host and reading, a refusal
and a missing reading counted apart, an unpublished version by its wire value, a name from DNS
marked; the three cases of the capture; the capture’s note always there; nothing the report
prints says passed; pinning is never worded about the app.EvidenceReportLayoutTests, on a report made by hand and a measurer of fixed-width characters,
nearly all of them over fifty page heights: columns that fit get what they ask for, a wide one
gets what the narrow ones leave, wide ones split evenly and never get less than an even share, a
narrow one is not squeezed, what is left over goes to the column that asked for most, narrow
columns measure the same with or without room to spare; A4; an empty report is one page; the
body frame; every character of the report is on some page; every line and every fill inside
the body frame; no two lines on the same spot; no page opens with a gap; no line of a broken text
alone; no heading last on a page; a fact that stays with the next is on the page where the next
begins — and without the mark it is not; a row that fits a page is not split; a table repeats its
header on every page it goes on to; no header without a row; a rule under every row; a table as
wide as the text; a cell inside its column; one label column per section; a label never over half
the line; a quoted note with its bar on every page and a paragraph without; the footer of every
page; a session line too long for it; the same report composed the same way.EvidenceReportPDFTests, with the real typography and PDFKit as the reader: breaking a text
into lines loses nothing but white space, in every style and width, with a 64-digit hash, a host
name, line breaks and non-Latin text; every line fits the width it was broken for; a label and
its value share a line height; every ink is a fixed grey that shows on paper in either
appearance; the PDF has the layout’s pages, each on A4; every line of the layout can be read
back from its page; the title and the tool in the metadata; on A4 the real report keeps every
verdict with its coverage and every line inside the margins.EvidenceExportPresentationTests: a session with no packets is not a complete capture; a capture
with nothing written is incomplete, not empty; only the reasons that have packets are listed, in
the order of capture.json; a complete capture is one sentence and shows no zeroes; every
sentence in its singular and its plural; packets from outside the session are counted together,
explained, and come after what is missing; what the summary holds; the summary quotes the bundle
rather than rewording it; nothing on the sheet says passed; each of the eight failures has a
sentence of its own, a catalogue that is not bundled is named and not replaced, a changed history
says to export again, and the technical detail travels apart.EvidenceBundleTests: the three refusals; a session with no flows still gives every document;
what session.json says of an audit and of a baseline; markers are the session’s own, by
instant, and another session’s does not date the flows; findings are numbered in order and each
flow cites its own; a finding carries what it states in its own fields, and a weak upstream
reading says whose connection it was; a requirement cites the findings behind its verdict, for
every verdict; every requirement that can get a verdict prints its coverage; the catalogue is
cited with its documents; every reason a check could not look, with its details; every flow
lands once in each check; a flow with every reading, a ceiling, a refusal, a name from DNS and
each certificate visibility; the encoded documents are the same bytes every time and readable
as plain JSON; no wording says passed; pinning is worded about the host.EvidenceFlowsCSVTests: the headers are unique and an empty document is only the header; every
row has the header’s columns; a flow’s readings flattened; a reading that was not made is an
empty cell; rows keep the order given; quoting; the four leading characters that are neutralised,
and an ordinary name left alone.EvidenceManifestTests: the digest against the FIPS 180-2 vectors, in one piece and fed in
pieces; an entry of a file in memory; files sorted by name; every refusal; the manifest of a
bundle covers its documents with the digest of their bytes and what is written apart; the
manifest as a file.EvidenceCaptureWriterTests, against a real FlowStore, files written by a real PcapWriter
and a pcapng reader that shares no code with the writer: the session’s packets and only those,
in capture order across a rotation, each with its bytes, instant, direction and comment; the
entry is the digest of the bytes on disk and enters the manifest; the same session gives the
same bytes and replaces an earlier capture; a flow’s findings in its packets’ comments; a packet
cut by snaplen, and the largest snaplen on the interface; every reason a packet has no
bytes — a deleted file, a file that is not a capture, a record cut short, never captured — per
flow and per source file; a session with no bytes still gives a file that opens; packets from
before and after the session, with its first and last instant inside; packets of an unlisted
flow stop the export and touch nothing; a missing folder; the tally accounts for every packet;
capture.json key by key.PcapngFormatTests: the section header and the interface byte for byte; a packet’s lengths and
its instant above 2³²; padding for every length; the comment in UTF-8; the direction flags; an
option too long is refused; blocks read back as one file.AuditStoreTests (packets of a session): counts by flow and file, with a flow from outside
sharing the file; a packet without capture is not counted in file 0; a file’s packets in offset
order with their flow and direction.RequirementCatalogueTests: a well written catalogue is read whole; each TLS version the format
names and each it does not; every refusal listed in § The format, with the requirement it is
in; two kinds of one check fit a rule; every finding kind belongs to one check.RequirementAssessmentTests: every row of the verdict table; a contrary finding contradicts
beside satisfied flows; only the kinds of the rule count; pinning with only refusals, with both
outcomes and without a trusted CA; consent without a marker and in a baseline is not assessed;
the coverage summary is the check’s own.RequirementCatalogueLibraryTests: the bundled catalogue loads and cites its two documents;
what it says of each requirement; every finding kind bears on one of its requirements; a project
without a catalogue gets the default, one that names it gets it, and one that names another is
refused; a session assessed requirement by requirement, with the catalogue’s own minimum.SessionDomainsTests: every row of the domain table; the host is normalised and a name from DNS
with no competition is the same domain as an announced one; a domain can be seen by one flow and
a candidate of another; unnamed flows are kept apart, an empty SNI included; first-appearance
order; flows with the same reading are one sighting and another source is another; a candidate
flow lends its version to no domain.ReleaseDiffTests: every refusal and the first that fails; the later session is the one that
started later whatever the order given or the release, by id on a tie; new, gone and in both,
each with the observations of both sides; the order of the result; the standing against the
allowlist, the headline, and none without an allowlist; every way a candidate keeps a domain
from being classified; unnamed flows on each side; the floor lowered, raised, held and not
ordered; the same set in another order or count is no change; QUIC counts as the app’s TLS 1.3;
an upstream reading is never compared with the app’s own and is compared with another upstream
one; a side with no reading; a new or gone domain has no TLS change.PinningAssessmentTests: every row of the pinning table; the host is cited normalised; an
outcome whose flow has only a resolved name, or an empty SNI, is not assessed; the reason of a
session without inspection or without a trusted CA is the first that fails, and reaches the
flows with an outcome too; TLS with no outcome is never acceptance, on any port; QUIC from
either end and of any version; not applicable does not depend on the conditions; the gap
identifiers are stable.ConsentAssessmentTests: every row of the consent table; the interval is the earliest and the
latest instant whatever the order of the markers, ignores other kinds — a custom marker named
like consent included — and other sessions’ markers; the first packet decides, for a flow that
outlived consent and for one older than the session; the instant of the marker itself is
after; the gap identifiers are stable.HostAssessmentTests: every row of the table above; a wildcard’s own apex is outside; the host
is cited normalised; an SNI decides alone; an allowed winner does not clear an unlisted candidate
and an unlisted winner with an allowed candidate is not a finding; an empty SNI is no name; ECH
with an announced name is judged by that name; the reason identifiers are stable.EncryptionAssessmentTests: every row of the table above; an HTTP request is cleartext on any
port and whatever the status says; a plaintext status alone is not cleartext and an encrypted
one alone is not encrypted.TLSVersionAssessmentTests: every row of the table above; the threshold is the one given, for the
version and for the offer; a listed weaker version outweighs an unrecognised one beside it; which
versions are published; the reading without a threshold is the one judged, an unpublished
version included, and a server’s answer outweighs a QUIC reading.FindingsClassifierTests: flows that prove the same thing are one finding and a different version
or source is another; flows seen in the clear are one finding; first-appearance order; each check
places every flow exactly once; a session where nothing could be read — or recorded before the
openings were — has neither findings nor satisfied flows; a policy needs a published
minimum; connections to the same unlisted host are one finding and unnamed flows one per reason;
the host check places every flow and none as not applicable; without an allowlist nothing is
unexpected and nothing is expected; pinning is one finding per host and outcome, and a host
with both gives both; without a trusted CA no pinning is reported; the pinning check places
every flow and none as satisfied; the flows opened before consent are one finding; without a
consent marker nothing is before and nothing is after; between two markers nothing is stated; a
baseline has no consent to precede; the order of a flow’s several findings; the kind
identifiers are stable.DomainPatternTests: normalisation, the canonical text round-trips, every rejection, and the edges
of a wildcard (own suffix, any depth, same trailing letters, a name that continues past the suffix).AuditStoreTests: a project and a session read back as written; the allowlist keeps its order; a
duplicate pattern writes nothing; one open session, refused by the code and by the schema; ending
rules; markers ordered by instant and refused outside an open session; the four tagging rules above,
including a second store standing in for the extension; capture files derived, ignoring packets
without capture; deleting a project keeps the flows; clearAll keeps the projects; a database
stopped at v5 migrates without losing anything; a project rewritten keeps its sessions and obeys
the rules of creation; a deleted session takes its markers, untags its flows and stops the tagging;
pruning leaves a session’s flows alone until the session is deleted; the evidence files are those of
every session and none from outside.AuditStoreTests also covers marking the open session: the marker lands in it and names its
project; with no session (none at all, or one already ended) nothing is written and none is opened;
a session ended by a second store — the app, seen from the control — is seen by the one that marks;
and the rules of any marker still apply.OpenSessionMarkingTests: every option writes its own kind with the instant it was given; no open
session, an unopenable database and a marker the store refuses are three reports and none of them
is placed; the control’s three states.AuditMarkerIntentCopyTests: the options are the session screen’s fixed markers, name and symbol;
the intent marks consent by default and never opens the app; the exact copy of the confirmation
and of the two errors.AuditViewModelTests: coming back to the foreground shows a marker placed from outside, and a
session deleted meanwhile is gone. Exporting leaves the bundle to be shown before it is shared,
with the instant of the tap; every failure of the export is a notice and never a sheet, an untyped
one included; a failed export does not take away a bundle already shown; a session deleted
elsewhere closes its screen when the export finds out; while a bundle is being written nothing
else writes and a second export is ignored; and, against the real EvidenceExporter, a closed
session gives an archive on disk and an open one is refused in a sentence.RetentionPlannerTests, CaptureHeadroomTests, StorageManagerTests: evidence is skipped by both
limits, the cause of an unmet size limit is told apart, and a cleanup that cannot read the evidence
deletes nothing.