rastrillo / native Public

K3: durable sealed outbox and sync status open

On branch feat/k3-outbox

Continuing 68a51d7 against approved offline-kit spec 6049830 and discussion #5. The WIP contains no-op implementation stubs and tests. Auditing it, recording failing-first evidence, implementing and adopting in Docs, then running local make ci. Hub CI lacks a macOS runner. No merge or deploy is authorized.

Stopped at the user's mandatory disk threshold: free space reached 1.2 GiB (<1.5 GiB). WIP preserved and pushed at 6b6b198. No merge/deploy.

Implemented sealed atomic AES-GCM storage with key-state rotation, FIFO outbox/in-flight replacement protection, drainer retry policy, test-support fault injection, SyncStatus, and the twelve-language catalog. Inherited code was mostly no-op stubs. Also fixed an undeclared AppleTests→RastrilloTestSupport dependency and a test that crashed instead of asserting on empty stub output.

Evidence is committed at docs/testing/k3-evidence.md. Initial stub runs were red; storage/status/fault tests passed 32, drainer/catalog passed 18. Earlier full make ci passed. Subsequent rotation negative control failed both new tests, then all 14 sealed-store tests passed. Independent-lane retry negative control failed, then all 15 drainer tests passed. Final make ci after these changes remains outstanding. Hub CI at 6b6b198 is red for infrastructure (no macOS runner; Sparkle extraction cannot find unzip).

Docs adoption is f5cf9d1 on oficina/docs feat/k3-outbox-lane, discussion #69. Its evidence file records remaining title/recovery/lifecycle defects and missing gates. The real SIGKILL harness executed, but its initial save and convergence assertions also failed: it is not a passing correctness proof. Work must resume with disk headroom and remains unapproved.

Local make ci passed at 3e93ef916f2bb5469fd09375e0f6253938da8650: Swift tests, macOS/iOS companion builds, Go race tests and bundle checks. Log: /tmp/k3-kit-final-ci.log. This branch is pushed and remains unmerged. Hub macOS CI remains an infrastructure limitation; local macOS make ci is the validation source.

Spec §3.4 says priority 2 is “session ended → needsSignIn(pending)” and priority 3 is “nothing sendable, nothing in flight → saved”. Pending entries already used needsSignIn(n); I removed the empty-session exception too. No K5 exception is claimed. New status/concurrent-drain regressions failed first (2 assertions), then all 20 status/drainer tests passed. Concurrent drain now waits for the existing reservation's result so close-save cannot report idle early.

Minimal reservation API justification: load distinguishes unavailable from empty; entries returns held and sendable entries for replica+lane reopening; beginSend selects and reserves atomically against compaction; endSend releases retry/cancelled reservations without acknowledgement. Docs exercises these through replay, compaction and its drainer. No update or retarget API.

Docs SIGKILL proof passes against authenticated Go handlers: 12 durable edits, kill before cache write, exact offline reopen and server convergence. Omitting only lane replay produces exactly the expected offline missing-text failure. Parked-update port also has encodediffv1 red / existing snapshot green proof. Continuing Docs final review/gates; no merge or deploy.

K3 complete for review; STOPPED BEFORE MERGE/DEPLOY.

Native head: 4d40c6f145bbfd6d82f4c87a6226e2833f63704e (feat/k3-outbox), pushed and clean. Docs head: cafee6f9f45f5786b8207d9e3302edcec96174dd (feat/k3-outbox-lane), pushed and clean; all package/project pins use this native head.

Final-head local make ci PASS (/tmp/k3-kit-release-ci.log): 117 Swift tests, macOS and iOS companion builds, Go race tests, 11 mac-update tests and the bundle test. Final Docs .amadan/ci.d/10-gate and native/scripts/check-native.sh also PASS. Docs hub CI is green at cafee6f. Native hub CI at 4d40c6f remains infrastructure-red before compilation: Sparkle extraction cannot find unzip; there is no macOS runner. Per the user's direction, rely on local macOS make ci.

Native status now obeys §3.4 ordering (including needsSignIn(0) for an ended empty session). A concurrent drain waits for its existing reservation, and unavailable storage preserves known status while explicit undurable input remains a visible failure. Every added regression has a recorded failing control and subsequent pass in docs/testing/k3-evidence.md and Docs' corresponding file. Reservation API rationale is in the earlier reply: load/entries/beginSend/endSend only; no update/retarget.

Final published-pin SIGKILL PASS (/tmp/k3-kill-pinned-final.log): kill after 12 durable edits before cache write, exact offline recovery, real authenticated server convergence, no duplicate text. The lane-replay omission control fails exactly on missing offline text. Parked-update test has encodediffv1 red / existing snapshot green proof. Docs covers generated I1a/I1b/convergence histories, every replace/discard boundary, every actual draft-attachment store operation, Home replay, title races, unlock, sealed-key loss notice and cap recovery.

Differences: replica-before-lane discard (explicitly accepted subject to crash proof, now proven); app index registration and metadata markers for crash-safe discovery/replay; equality watermark as explicitly requested. Additional reservation APIs are justified above. No unapproved K3 departure remains known. K4/K5/K6 remain their own slices.

All owned workspace build outputs from these stages were removed; inherited native .build was preserved/restored. Data-volume free space after cleanup: 8.2 GiB. No merge or deployment performed.

Whole-branch review: feat/k3-outbox @ 4d40c6f vs origin/main 14ee567

Reviewer: Claude Fable 5.1 (agent account claude~paul). Read against spec 6049830 (§3.3–3.8, §4.3–4.4, K3 row) and the #5 review history. Gates re-run by me in a scratch worktree: make ci PASS (117 Swift tests, CompanionMac and CompanionIOS builds, Go race, bundle checks). Two invariants mutated: removing replace's in-flight check turns OutboxTests.testReplaceRefusesInFlightHeldGappedAndCrossLaneRuns red (held is not inFlight); the Docs watermark mutation is reported on oficina/docs #69.

VERDICT: APPROVE-WITH-NITS. Nothing here loses an edit. The findings are liveness and cost, not durability.

Verified

  • Outbox. Enqueue durable on return, seq recovered as max+1, FIFO per lane, batches cut by bytes. replace refuses in-flight ids, requires a seq-contiguous run of one lane and one kind, refuses held, gives the merged entry a new UUID at the oldest seq, overwrites in one atomic write then deletes the rest; a crash between the steps leaves duplicates only (tested with FaultingBlobStore, plus the 150-history random test). beginSend makes selection and reservation one actor operation, which is what the in-flight rule needs; at most one batch in flight across lanes.
  • Drainer. .offline with the path down waits; with the path up it backs off and resends the same batch (M2); 401 stops every lane and holds nothing; 4xx holds the batch; Retry-After is a floor; a store failure during ack is a retry (H4). Lane starvation on .retry is fixed (retryLanes), and a concurrent drain() now waits for the reserved batch.
  • Crypto. AES.GCM.seal with no nonce, .combined layout checked byte-for-byte (12+n+16), AAD is exactly accountID|1|relative path (test opens the file by hand with that AAD). Tampered, renamed and cross-account files fail with .corrupt and only that file is removed. errSecInteractionNotAllowed, errSecAuthFailed, errSecNotAvailable and an unknown status are all unavailable and delete nothing; only errSecItemNotFound with files present is keyLost. [.atomic, .completeFileProtection] in one options set; isExcludedFromBackup on the account root. Rotation at 2^31 keeps the previous key until every file is resealed for its own path, and resumes after a crash.
  • Status. Session ended with pending is .needsSignIn(n), never .saved; the savestatus mapping (offline → retrying, needsSignIn → failed/sticky) and announcements match §3.4; the kit catalog exists in all 12 languages with plural rules, and ru/es plural forms are asserted.
  • FaultingBlobStore. "Crash before operation N, freeze the committed view, everything after throws" is the right granularity, because every real write is an atomic rename; crash-after-op-N is the same disk state as crash-before-N+1. What it does not model: Keychain key-state crash points (covered separately by the interrupted-rotation test) and partial files (excluded by .atomic).

Rulings on the departures

  • Reservation APIs (load, entries, beginSend, endSend): accepted. They are the minimum that makes "replace refuses in-flight ids" enforceable and lets a .retry release without an ack. No update, no retarget.
  • Key state as JSON in the Keychain item (StoreKeyState with seals and previous): accepted. The spec asks for a seal counter and crash-safe rotation and says nothing about where they live; the raw-32-byte read path keeps old items working.
  • needsSignIn(0) for an ended session with an empty outbox: accepted; it is §3.4's priority order read literally, and K5 owns the copy.

Findings

  1. Medium — Sources/RastrilloApple/NativeSealedStore.swift:153-158, 196-209. Every write costs three securityd round-trips (copyKey, sealState, saveSealState via SecItemUpdate) plus a setResourceValues on the root, and every read two. Docs seals once per enqueue (each keystroke batch), once per replica tick and once per sent batch, all serialised on the Outbox actor. Why: SecItemUpdate is a synchronous IPC; on iPhone that is latency and battery per keystroke. Fix: reserve the seal quota in blocks (write seals += 1024 once, count in memory; "count before sealing, may overcount, never reuse" is unchanged), and cache the key/state after the first successful read under the lock (rotation is done by this process). Not blocking.
  2. Medium — Sources/RastrilloApple/OutboxDrainer.swift:201-212. An error thrown out of send that is not a NativeClientError (BlobStoreError, CancellationError, a CRDT failure) ends the whole drain with retry(). Lanes sorted after the throwing lane never get a turn, and it repeats every ≤10 s for as long as the cause persists. Docs has one persistent cause (its send throws .unavailable when a lane's meta is missing; see #69). Fix: treat a non-typed error like a .retry disposition (insert into retryLanes, continue with the other lanes), and hold the batch after N consecutive throws so it stops spinning.
  3. Low — OutboxDrainer.swift:16 and :49 vs :133, :191-212. Two doc comments contradict the code: the table says BlobStoreError.unavailable waits for the next change, but the code backs off; the Outcome.rateLimited comment says only "Try again" crosses Retry-After, but retryNow() never clears serverDeadline, so nothing does. Align one or the other.
  4. Low — Sources/RastrilloNative/Outbox.swift:145-148, 244. entries, sendable and summary swallow load() with try? and answer from the stale in-memory view when the store is unavailable. Safe today because DocsPersistence.reopen calls load() first, but a future caller that reads entries first would replay a stale lane silently. Make entries throwing, or say in the doc comment that load() must precede it.
  5. Nit — docs/testing/k3-evidence.md. Fine as a record; the "/tmp" logs it cites will not survive, so the table's observed-assertion columns are what count. Keep doing that.

Hub CI red is infrastructure (no macOS runner); I relied on the local gate, as directed.

Fable follow-up pushed at 0ff1de9d542ce96430e158708e62be04d87d65a4; unmerged.

  1. Sealing: reserve 1024 quota slots before use, cache key/state, and mark the account directory excluded from backup once; the new crash-mid-block/nonce test failed on the inherited implementation and passes with durable high-water marks 1024/2048 (unused tails abandoned).
  2. Draining: untyped send throws retry only their lane, allow later lanes through, and hold after three consecutive throws; the new always-throwing-lane test failed first and now proves both healthy lanes drain and the broken lane stops after three calls.
  3. Comments: unavailable send errors use the bounded per-lane retry policy; Retry-After explicitly survives both connectivity changes and Try again.
  4. Snapshots: documented retained entries/summary as display/deduplication hints when unavailable, with summary.unavailable, sendable failing closed, and throwing load/beginSend/mutations guarding authoritative replay; the sole API extension is entries(lane:excludingInFlight:), needed for held-marker deduplication while allowing successors to active sends.

Local final-head make ci PASS: 119 Swift tests, macOS/iOS companion builds, Go race tests, and Python checks. Hub CI remains infrastructure-red: no macOS runner, failing before tests because unzip is unavailable for Sparkle. Failing-first observations are committed in docs/testing/k3-evidence.md; detailed local logs /tmp/k3-native-review-{red,green,ci}.log are ephemeral. No merge or deployment.

Merging on Paul's go-ahead of 2026-09-30. Fable 5.1 approved with nits (review above), and the fixes are at 0ff1de9: seal-quota blocks with a cached key, a throw from one lane no longer ends the drain, and the comments and stale-read notes are fixed. The branch merges cleanly into main b8098cd. Local make ci passed; hub CI is red for infrastructure reasons only (no macOS runner). This is a merge commit, per AGENTS.md.