rastrillo / native Public

Implement sealed storage and FIFO outbox to preserve edits across process kills

Paul Campbell pushed by paul@keymail.dev 111c15f56a8a2305c45f49bf900777caf53b8ec9 parent 68a51d7
5 files changed, +369 −56
  • Sources/RastrilloApple/NativeSealedStore.swift +103 −8
  • Sources/RastrilloNative/Outbox.swift +152 −18
  • Sources/RastrilloNative/SyncStatus.swift +25 −4
  • Sources/RastrilloTestSupport/FaultingBlobStore.swift +69 −26
  • docs/testing/k3-evidence.md +20 −0

CI failed — run details

diff --git a/Sources/RastrilloApple/NativeSealedStore.swift b/Sources/RastrilloApple/NativeSealedStore.swift
index 3b75ec2..ff16d34 100644
--- a/Sources/RastrilloApple/NativeSealedStore.swift
+++ b/Sources/RastrilloApple/NativeSealedStore.swift
@@ -53,16 +53,111 @@ public struct KeychainKeyProvider: KeyProvider {
public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) }
}
-/// STUB for the red run.
+/// Atomic authenticated files, bound to their final relative paths. A lock
+/// covers key creation and disk mutations; no plaintext is written to disk.
public final class NativeSealedStore: BlobStore, @unchecked Sendable {
public static let formatVersion = 1
public let root: URL
public let accountID: String
- public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID }
- public func write(_ data: Data, to path: String) throws {}
- public func read(_ path: String) throws -> Data? { nil }
- public func list(_ directory: String) throws -> [String] { [] }
- public func remove(_ path: String) throws {}
- public func wipe() throws {}
- static let writeOptions: Data.WritingOptions = []
+ private let keys: KeyProvider
+ private let lock = NSLock()
+ private let fm = FileManager.default
+ public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID; self.keys = keys }
+ static let writeOptions: Data.WritingOptions = [.atomic, .completeFileProtection]
+ private func url(_ path: String, allowRoot: Bool = false) throws -> URL {
+ guard (allowRoot || !path.isEmpty), !path.hasPrefix("/"), !path.contains("\0"),
+ !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." || $0 == "." }) else { throw BlobStoreError.unavailable }
+ let result = root.appendingPathComponent(path)
+ // Reject symlinks too: a relative spelling alone does not contain I/O.
+ let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL.path
+ let resolved = result.resolvingSymlinksInPath().standardizedFileURL.path
+ guard resolved == resolvedRoot || resolved.hasPrefix(resolvedRoot + "/") else { throw BlobStoreError.unavailable }
+ return result
+ }
+ private func missing(_ error: Error) -> Bool {
+ let error = error as NSError
+ return error.domain == NSCocoaErrorDomain && [NSFileReadNoSuchFileError, NSFileNoSuchFileError].contains(error.code)
+ }
+ private func io<T>(_ body: () throws -> T) throws -> T {
+ do { return try body() } catch let error as BlobStoreError { throw error } catch { throw BlobStoreError.unavailable }
+ }
+ private func hasFiles(_ directory: URL) throws -> Bool {
+ let children: [URL]
+ do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) }
+ catch { if missing(error) { return false }; throw error }
+ for child in children {
+ if try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true {
+ if try hasFiles(child) { return true }
+ } else { return true }
+ }
+ return false
+ }
+ private func key(create: Bool) throws -> SymmetricKey? {
+ let (status, key) = keys.copyKey()
+ if status == errSecSuccess, let key { return key }
+ guard status == errSecItemNotFound else { throw BlobStoreError.unavailable }
+ if try hasFiles(root) {
+ try fm.removeItem(at: root)
+ throw BlobStoreError.keyLost
+ }
+ guard create else { return nil }
+ let new = SymmetricKey(size: .bits256)
+ let added = keys.addKey(new)
+ if added == errSecSuccess { return new }
+ if added == errSecDuplicateItem {
+ let (status, existing) = keys.copyKey()
+ if status == errSecSuccess, let existing { return existing }
+ }
+ throw BlobStoreError.unavailable
+ }
+ private func aad(_ path: String) -> Data { Data("\(accountID)|\(Self.formatVersion)|\(path)".utf8) }
+ public func write(_ data: Data, to path: String) throws {
+ try lock.withLock { try io {
+ let target = try url(path)
+ guard let key = try key(create: true) else { throw BlobStoreError.unavailable }
+ try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
+ var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true
+ try directory.setResourceValues(values)
+ let sealed = try AES.GCM.seal(data, using: key, authenticating: aad(path))
+ guard let combined = sealed.combined else { throw BlobStoreError.unavailable }
+ try combined.write(to: target, options: Self.writeOptions)
+ } }
+ }
+ public func read(_ path: String) throws -> Data? {
+ try lock.withLock { try io {
+ let target = try url(path)
+ let data: Data
+ do { data = try Data(contentsOf: target) } catch { if missing(error) { return nil }; throw error }
+ guard let key = try key(create: false) else { throw BlobStoreError.unavailable }
+ do { return try AES.GCM.open(AES.GCM.SealedBox(combined: data), using: key, authenticating: aad(path)) }
+ catch {
+ // Only an authentication/decode failure reaches this catch. I/O or
+ // Keychain refusal must never turn into a destructive corruption path.
+ try fm.removeItem(at: target)
+ throw BlobStoreError.corrupt(path)
+ }
+ } }
+ }
+ public func list(_ directory: String) throws -> [String] {
+ try lock.withLock { try io {
+ let target = try url(directory, allowRoot: true)
+ do {
+ return try fm.contentsOfDirectory(at: target, includingPropertiesForKeys: [.isRegularFileKey])
+ .filter { try $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true }.map(\.lastPathComponent).sorted()
+ } catch { if missing(error) { return [] }; throw error }
+ } }
+ }
+ public func remove(_ path: String) throws {
+ try lock.withLock { try io {
+ let target = try url(path)
+ do { try fm.removeItem(at: target) } catch { if !missing(error) { throw error } }
+ } }
+ }
+ public func wipe() throws {
+ try lock.withLock { try io {
+ let status = keys.deleteKey()
+ guard status == errSecSuccess || status == errSecItemNotFound else { throw BlobStoreError.unavailable }
+ do { try fm.removeItem(at: root) } catch { if !missing(error) { throw error } }
+ } }
+ }
}
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift
index 5c5afb4..da9b878 100644
--- a/Sources/RastrilloNative/Outbox.swift
+++ b/Sources/RastrilloNative/Outbox.swift
@@ -90,30 +90,164 @@ public enum OutboxError: Error, Sendable, Equatable {
case duplicateEntry(UUID)
}
-/// STUB for the red run: every behaviour missing.
+/// Serializes disk mutations and send reservations for one account. Memory is
+/// changed after each successful disk operation, including partial batch acks.
public actor Outbox {
public nonisolated let account: String
public static let directory = "outbox"
- public init(store: BlobStore, account: String) { self.account = account }
- public func load() throws {}
+ private let store: BlobStore
+ private var values: [OutboxEntry] = []
+ private var flying: Set<UUID> = []
+ private var loaded = false
+ private var unavailable = false
+ private var lost = false
+ private var next: UInt64 = 1
+ private var subscribers: [UUID: AsyncStream<OutboxSummary>.Continuation] = [:]
+ public init(store: BlobStore, account: String) { self.store = store; self.account = account }
+ private func path(_ seq: UInt64) -> String { "outbox/" + String(format: "%016llx", seq) + ".entry" }
+ public func load() throws {
+ guard !loaded || unavailable else { return }
+ var recovered: [OutboxEntry] = []
+ do {
+ let names = try store.list(Self.directory)
+ for name in names where name.hasSuffix(".entry") {
+ let file = Self.directory + "/" + name
+ do {
+ guard let data = try store.read(file) else { continue }
+ guard let entry = try? JSONDecoder().decode(OutboxEntry.self, from: data), path(entry.seq) == file,
+ !recovered.contains(where: { $0.id == entry.id }) else {
+ try store.remove(file); lost = true; continue
+ }
+ recovered.append(entry)
+ } catch BlobStoreError.corrupt { lost = true }
+ }
+ values = recovered.sorted { $0.seq < $1.seq }
+ next = max(next, (values.last?.seq ?? 0) + 1)
+ loaded = true; unavailable = false; notify()
+ } catch BlobStoreError.keyLost {
+ values = []; flying = []; loaded = true; unavailable = false; lost = true; notify()
+ } catch { unavailable = true; notify(); throw error }
+ }
+ private func mutate<T>(_ body: () throws -> T) throws -> T {
+ do { let result = try body(); unavailable = false; notify(); return result }
+ catch { unavailable = true; notify(); throw error }
+ }
+ private func persist(_ entry: OutboxEntry) throws { try store.write(JSONEncoder().encode(entry), to: path(entry.seq)) }
@discardableResult
public func enqueue(lane: String, kind: String, payload: Data, id: UUID = UUID()) throws -> OutboxEntry {
- OutboxEntry(id: id, seq: 1, lane: lane, kind: kind, payload: payload, createdAt: Date())
+ try load()
+ guard !values.contains(where: { $0.id == id }) else { throw OutboxError.duplicateEntry(id) }
+ return try mutate {
+ let entry = OutboxEntry(id: id, seq: next, lane: lane, kind: kind, payload: payload, createdAt: Date())
+ try persist(entry); values.append(entry); next += 1; return entry
+ }
+ }
+ public func entries(lane: String) -> [OutboxEntry] {
+ try? load()
+ return values.filter { $0.lane == lane }
+ }
+ public func sendable(lane: String, maxBytes: Int? = nil) -> [OutboxEntry] {
+ try? load()
+ guard !unavailable else { return [] }
+ var result: [OutboxEntry] = []; var bytes = 0
+ for entry in values where entry.lane == lane {
+ if entry.hold != nil { break }
+ // Compaction may use the suffix after a reserved batch, but beginSend
+ // below never sends it before the earlier batch finishes.
+ if flying.contains(entry.id) { continue }
+ if let maxBytes, !result.isEmpty, bytes + entry.payload.count > maxBytes { break }
+ result.append(entry); bytes += entry.payload.count
+ }
+ return result
+ }
+ public func beginSend(lane: String, maxBytes: Int? = nil) throws -> [OutboxEntry] {
+ try load()
+ guard flying.isEmpty else { return [] }
+ let batch = sendable(lane: lane, maxBytes: maxBytes)
+ flying.formUnion(batch.map(\.id)); notify(); return batch
+ }
+ public func endSend(_ ids: [UUID], failed: Bool = true) {
+ for id in ids {
+ flying.remove(id)
+ if failed, let index = values.firstIndex(where: { $0.id == id }) { values[index].attempts += 1 }
+ }
+ notify()
}
- public func entries(lane: String) -> [OutboxEntry] { [] }
- public func sendable(lane: String, maxBytes: Int?) -> [OutboxEntry] { [] }
- public func beginSend(lane: String, maxBytes: Int?) throws -> [OutboxEntry] { [] }
- public func endSend(_ ids: [UUID], failed: Bool = true) {}
@discardableResult
public func replace(_ ids: [UUID], with payload: Data) throws -> OutboxEntry {
- OutboxEntry(id: UUID(), seq: 0, lane: "", kind: "", payload: payload, createdAt: Date())
- }
- public func ack(_ ids: [UUID]) throws {}
- public func hold(_ ids: [UUID], _ hold: Hold) throws {}
- public func release(_ id: UUID) throws {}
- public func discard(lane: String) throws {}
- public func purge() throws {}
- public func acknowledgeLoss() {}
- public var summary: OutboxSummary { OutboxSummary() }
- public var changes: AsyncStream<OutboxSummary> { AsyncStream { $0.finish() } }
+ try load()
+ guard !ids.isEmpty, Set(ids).count == ids.count else { throw OutboxError.notContiguous }
+ if ids.contains(where: { flying.contains($0) }) { throw OutboxError.inFlight }
+ let run = try ids.map { id in
+ guard let entry = values.first(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) }
+ return entry
+ }.sorted { $0.seq < $1.seq }
+ let first = run[0]
+ let lane = values.filter { $0.lane == first.lane }
+ guard run.allSatisfy({ $0.lane == first.lane && $0.kind == first.kind }),
+ let start = lane.firstIndex(where: { $0.id == first.id }),
+ Array(lane.dropFirst(start).prefix(run.count)).map(\.id) == run.map(\.id) else { throw OutboxError.notContiguous }
+ let sendableIDs = Set(sendable(lane: first.lane).map(\.id))
+ guard run.allSatisfy({ $0.hold == nil && sendableIDs.contains($0.id) }) else { throw OutboxError.held }
+ return try mutate {
+ let merged = OutboxEntry(id: UUID(), seq: first.seq, lane: first.lane, kind: first.kind, payload: payload, createdAt: first.createdAt)
+ try persist(merged)
+ values[values.firstIndex(where: { $0.id == first.id })!] = merged
+ for entry in run.dropFirst() {
+ try store.remove(path(entry.seq)); values.removeAll { $0.id == entry.id }
+ }
+ return merged
+ }
+ }
+ public func ack(_ ids: [UUID]) throws {
+ try load()
+ try mutate {
+ for id in ids {
+ guard let entry = values.first(where: { $0.id == id }) else { continue }
+ try store.remove(path(entry.seq)); values.removeAll { $0.id == id }; flying.remove(id)
+ }
+ }
+ }
+ public func hold(_ ids: [UUID], _ hold: Hold) throws {
+ try load()
+ try mutate {
+ for id in ids {
+ guard let index = values.firstIndex(where: { $0.id == id }) else { continue }
+ var entry = values[index]; entry.hold = hold
+ try persist(entry); values[index] = entry; flying.remove(id)
+ }
+ }
+ }
+ public func release(_ id: UUID) throws {
+ try load()
+ guard let index = values.firstIndex(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) }
+ try mutate { var entry = values[index]; entry.hold = nil; try persist(entry); values[index] = entry }
+ }
+ public func discard(lane: String) throws { try load(); try ack(values.filter { $0.lane == lane }.map(\.id)) }
+ public func purge() throws {
+ try load()
+ try mutate { try store.remove(Self.directory); values = []; flying = [] }
+ }
+ public func acknowledgeLoss() { lost = false; notify() }
+ private var currentSummary: OutboxSummary {
+ var result = OutboxSummary(); result.unavailable = unavailable; result.lostUnsent = lost
+ for entry in values {
+ var lane = result.lanes[entry.lane] ?? OutboxSummary.Lane()
+ if let hold = entry.hold { lane.held += 1; if lane.firstHold == nil { lane.firstHold = hold.reason } }
+ else { lane.pending += 1 }
+ if flying.contains(entry.id) { lane.inFlight += 1 }
+ lane.bytes += entry.payload.count; result.lanes[entry.lane] = lane
+ }
+ return result
+ }
+ public var summary: OutboxSummary { try? load(); return currentSummary }
+ private func notify() { for subscriber in subscribers.values { subscriber.yield(currentSummary) } }
+ private func unsubscribe(_ id: UUID) { subscribers[id] = nil }
+ public var changes: AsyncStream<OutboxSummary> {
+ try? load()
+ let (stream, continuation) = AsyncStream<OutboxSummary>.makeStream(bufferingPolicy: .bufferingNewest(1))
+ let id = UUID(); subscribers[id] = continuation; continuation.yield(currentSummary)
+ continuation.onTermination = { [weak self] _ in Task { await self?.unsubscribe(id) } }
+ return stream
+ }
}
diff --git a/Sources/RastrilloNative/SyncStatus.swift b/Sources/RastrilloNative/SyncStatus.swift
index 5c20188..f97f943 100644
--- a/Sources/RastrilloNative/SyncStatus.swift
+++ b/Sources/RastrilloNative/SyncStatus.swift
@@ -30,9 +30,30 @@ public enum SyncStatus: Sendable, Equatable {
case retrying(pending: Int)
public enum SaveState: String, Sendable { case saved, saving, retrying, failed }
- public var saveState: SaveState { .saved }
- public var isSticky: Bool { false }
- public var isAnnounced: Bool { false }
+ public var saveState: SaveState {
+ switch self {
+ case .saved: return .saved
+ case .saving: return .saving
+ case .offline, .retrying: return .retrying
+ case .failed, .needsSignIn: return .failed
+ }
+ }
+ public var isSticky: Bool { saveState == .failed }
+ public var isAnnounced: Bool {
+ switch self { case .offline, .needsSignIn, .failed: return true; default: return false }
+ }
public init(summary: OutboxSummary, sending: Bool, sessionEnded: Bool, offline: Bool, backingOff: Bool,
- localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) { self = .saved }
+ localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) {
+ let pending = count(summary)
+ if let held = summary.lanes.sorted(by: { $0.key < $1.key }).compactMap({ $0.value.firstHold }).first {
+ self = .failed(pending: pending, reason: .held(held))
+ } else if let localFailure { self = .failed(pending: pending, reason: localFailure) }
+ else if summary.unavailable { self = .failed(pending: pending, reason: .cannotSaveOnDevice) }
+ else if summary.isEmpty && summary.inFlight == 0 { self = .saved }
+ else if sessionEnded { self = .needsSignIn(pending: pending) }
+ else if sending || summary.inFlight > 0 { self = .saving }
+ else if offline { self = .offline(pending: pending) }
+ else if backingOff { self = .retrying(pending: pending) }
+ else { self = .saving }
+ }
}
diff --git a/Sources/RastrilloTestSupport/FaultingBlobStore.swift b/Sources/RastrilloTestSupport/FaultingBlobStore.swift
index 14365fc..c45658c 100644
--- a/Sources/RastrilloTestSupport/FaultingBlobStore.swift
+++ b/Sources/RastrilloTestSupport/FaultingBlobStore.swift
@@ -4,41 +4,84 @@ import RastrilloApple
import RastrilloNative
import Security
-/// STUB for the red run.
+/// Thread-safe in-memory disk. Copies model a new process reading committed bytes.
public final class MemoryBlobStore: BlobStore, @unchecked Sendable {
- public init(_ files: [String: Data] = [:]) {}
- public var contents: [String: Data] { [:] }
- public func write(_ data: Data, to path: String) throws {}
- public func read(_ path: String) throws -> Data? { nil }
- public func list(_ directory: String) throws -> [String] { [] }
- public func remove(_ path: String) throws {}
+ private let lock = NSLock()
+ private var files: [String: Data]
+ public init(_ files: [String: Data] = [:]) { self.files = files }
+ public var contents: [String: Data] { lock.withLock { files } }
+ public func write(_ data: Data, to path: String) throws { lock.withLock { files[path] = data } }
+ public func read(_ path: String) throws -> Data? { lock.withLock { files[path] } }
+ public func list(_ directory: String) throws -> [String] {
+ lock.withLock {
+ let prefix = directory.isEmpty ? "" : directory + "/"
+ return files.keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) }
+ .filter { !$0.contains("/") }.sorted()
+ }
+ }
+ public func remove(_ path: String) throws {
+ lock.withLock { files = files.filter { !(path.isEmpty || $0.key == path || $0.key.hasPrefix(path + "/")) } }
+ }
}
public struct SimulatedCrash: Error, Sendable, Equatable {
public let operation: Int
public init(operation: Int) { self.operation = operation }
}
+/// Crash *before* operation N (zero based). Once crashed, the committed view
+/// freezes and every later operation throws, just as a dead process cannot write.
public final class FaultingBlobStore: BlobStore, @unchecked Sendable {
public enum Operation: Sendable, Equatable { case write(String), read(String), list(String), remove(String) }
+ private let lock = NSLock()
private let base: MemoryBlobStore
- public init(_ base: MemoryBlobStore = MemoryBlobStore(), crashAt: Int? = nil) { self.base = base }
- public func crash(after operations: Int) {}
- public var unavailable: Bool { get { false } set {} }
- public var hasCrashed: Bool { false }
- public var operationCount: Int { 0 }
- public var operations: [Operation] { [] }
- public var committed: MemoryBlobStore { MemoryBlobStore() }
- public func write(_ data: Data, to path: String) throws { try base.write(data, to: path) }
- public func read(_ path: String) throws -> Data? { try base.read(path) }
- public func list(_ directory: String) throws -> [String] { try base.list(directory) }
- public func remove(_ path: String) throws { try base.remove(path) }
+ private var schedule: Int?
+ private var frozen: [String: Data]?
+ private var history: [Operation] = []
+ private var locked = false
+ public init(_ base: MemoryBlobStore = MemoryBlobStore(), crashAt: Int? = nil) { self.base = base; schedule = crashAt }
+ public func crash(after operations: Int) { lock.withLock { schedule = history.count + operations } }
+ public var unavailable: Bool { get { lock.withLock { locked } } set { lock.withLock { locked = newValue } } }
+ public var hasCrashed: Bool { lock.withLock { frozen != nil } }
+ public var operationCount: Int { lock.withLock { history.count } }
+ public var operations: [Operation] { lock.withLock { history } }
+ public var committed: MemoryBlobStore { lock.withLock { MemoryBlobStore(frozen ?? base.contents) } }
+ private func perform<T>(_ operation: Operation, _ body: () throws -> T) throws -> T {
+ try lock.withLock {
+ if frozen != nil || schedule == history.count {
+ if frozen == nil { frozen = base.contents }
+ throw SimulatedCrash(operation: history.count)
+ }
+ if locked { throw BlobStoreError.unavailable }
+ let value = try body()
+ history.append(operation)
+ return value
+ }
+ }
+ public func write(_ data: Data, to path: String) throws { try perform(.write(path)) { try base.write(data, to: path) } }
+ public func read(_ path: String) throws -> Data? { try perform(.read(path)) { try base.read(path) } }
+ public func list(_ directory: String) throws -> [String] { try perform(.list(directory)) { try base.list(directory) } }
+ public func remove(_ path: String) throws { try perform(.remove(path)) { try base.remove(path) } }
}
public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable {
- public init(key: SymmetricKey? = nil) {}
- public func failCopies(with status: OSStatus?) {}
- public func failAdds(with status: OSStatus?) {}
- public var storedKey: SymmetricKey? { nil }
- public var log: [String] { [] }
- public func copyKey() -> (OSStatus, SymmetricKey?) { (errSecItemNotFound, nil) }
- public func addKey(_ new: SymmetricKey) -> OSStatus { errSecSuccess }
- public func deleteKey() -> OSStatus { errSecSuccess }
+ private let lock = NSLock()
+ private var key: SymmetricKey?
+ private var copyFailure: OSStatus?
+ private var addFailure: OSStatus?
+ private var history: [String] = []
+ public init(key: SymmetricKey? = nil) { self.key = key }
+ public func failCopies(with status: OSStatus?) { lock.withLock { copyFailure = status } }
+ public func failAdds(with status: OSStatus?) { lock.withLock { addFailure = status } }
+ public var storedKey: SymmetricKey? { lock.withLock { key } }
+ public var log: [String] { lock.withLock { history } }
+ public func copyKey() -> (OSStatus, SymmetricKey?) {
+ lock.withLock { history.append("copy"); return copyFailure.map { ($0, nil) } ?? (key == nil ? errSecItemNotFound : errSecSuccess, key) }
+ }
+ public func addKey(_ new: SymmetricKey) -> OSStatus {
+ lock.withLock {
+ history.append("add")
+ if let addFailure { return addFailure }
+ guard key == nil else { return errSecDuplicateItem }
+ key = new; return errSecSuccess
+ }
+ }
+ public func deleteKey() -> OSStatus { lock.withLock { history.append("delete"); key = nil; return errSecSuccess } }
}
diff --git a/docs/testing/k3-evidence.md b/docs/testing/k3-evidence.md
new file mode 100644
index 0000000..163ebf0
--- /dev/null
+++ b/docs/testing/k3-evidence.md
@@ -0,0 +1,20 @@
+# K3 validation (in progress)
+
+Continuing WIP 68a51d7 against offline-kit spec 6049830 (discussion #5).
+The inherited implementations were explicitly no-op stubs, not working code.
+
+2026-09-24: ran the inherited tests against those stubs before implementation.
+`swift test` failed on missing enqueues, sends, replacement ordering, status
+mapping and store writes. One inherited outbox test indexed an empty result
+and terminated the process. A separate filtered run of `SyncStringsTests`,
+`SyncStatusTests`, `FaultingBlobStoreTests`, and `SealedStoreTests` executed 24
+tests with 78 failures (including missing sealed files). Local transcripts:
+`/tmp/k3-kit-red.log`, `/tmp/k3-kit-red-rest.log`.
+
+After implementation, the storage/status/fault suite passed 32 tests and the
+drainer/catalog suite passed 18. `make ci` passed, including macOS and iOS
+companion builds, Go race tests, and Python bundle tests; transcript:
+`/tmp/k3-kit-ci-1.log`. This is intermediate evidence, not final K3 approval.
+
+Remaining: complete Docs adoption and crash/convergence evidence, seal-count
+rotation, final audit and full gates. Nothing has been merged or deployed.