A multi-platform secrets manager for passwords, API keys, secret notes, and similar credentials, designed for personal and family use without depending on any operated service.
Existing password managers fall into two camps: open-source-but-self-hosted (Bitwarden / Vaultwarden, KeePass), or polished-but-vendor-controlled (1Password, Dashlane, Apple Keychain). Most users need something simple to install, free of charge, with no ongoing service dependency, that supports family sharing including the inheritance case where children retain access to a deceased parent's credentials decades later.
That last requirement is the hard one. A vault that protects a credential for thirty or fifty years must defend against attacks that don't yet exist — most prominently, attacks by future quantum computers against the asymmetric primitives that protect data shared between users. Secretary therefore uses post-quantum hybrid cryptography from v1: every recipient-to-recipient key wrap and every signature combines a classical primitive with a NIST-standardized post-quantum primitive, so an attacker must break both to recover plaintext.
Secretary is a client-only system. There is no server, no managed service, no hosted backend. Sync between devices uses any folder the user already has — iCloud Drive, Google Drive, Dropbox, OneDrive, a WebDAV mount on a home NAS, or a USB stick. Sharing between users uses any folder both parties can access.
Target platforms (planned):
- Desktop: macOS, Linux, Windows — Tauri 2 (Rust backend + Svelte/TypeScript frontend)
- Mobile: iOS, Android — native apps (SwiftUI / Jetpack Compose) over the uniffi bindings (ADR 0008)
- Browser autofill extensions: future
Target users:
- Individuals managing their own credentials
- Families sharing credentials selectively, including across generations (inheritance)
- Small teams, eventually
Not in scope:
- Server-mediated sync, real-time push notifications, server-side enforcement of policies
- Anonymity / metadata privacy from the user's chosen cloud-folder host
- Defense against compromise of the OS, hardware, or trusted computing base
- Coercion resistance / plausible deniability (may be added in a future format version)
┌──────────────────────────────────────┐
│ secretary-core (Rust) │
│ │
│ • Cryptographic primitives │
│ • Vault format read/write │
│ • Identity, recipients, sharing │
│ • Conflict resolution (CRDT) │
│ • Memory hygiene (zeroize, secret) │
└──────────────┬───────────────────────┘
│
┌────────────────┼────────────────────┐
│ │ │
direct crate dep PyO3 bindings uniffi (Swift / Kotlin)
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌──────────────┐ ┌─────────────────────┐
│ Tauri 2 client │ │ Python │ │ Native iOS / │
│ (Rust backend + │ │ (scripts, │ │ Android UI │
│ Svelte / TS UI) │ │ automation, │ │ (SwiftUI / Compose) │
│ │ │ analysis) │ │ + Shortcuts / │
│ macOS, Linux, │ │ │ │ AutoFill │
│ Windows │ │ Sub-project │ │ │
│ Sub-project D │ │ B │ │ Sub-project B-uniffi│
└─────────────────┘ └──────────────┘ └─────────────────────┘
The Rust core is the single source of truth for everything security-relevant — cryptography, vault parsing, key handling, conflict resolution. Desktop (macOS / Linux / Windows) is a single Tauri 2 codebase (Rust backend + Svelte/TypeScript frontend); secrets never leave Rust's deterministic-zeroize address space, and there is no localhost HTTP server (Tauri IPC is in-process via a custom URL scheme). Mobile (iOS / Android) is native — SwiftUI and Jetpack Compose apps consuming the core through the uniffi bindings, so they reach Keychain / Secure Enclave / Android Keystore / StrongBox + biometric-bound key release directly; uniffi is the mobile UI path. The Python (secretary-ffi-py) binding remains the automation / scripting path.
Why this architecture: see docs/adr/0001-rust-core.md for the original Rust-core decision, docs/adr/0007-d-row-tauri.md for the 2026-05 pivot of the desktop UI from NiceGUI to Tauri 2, and docs/adr/0008-native-mobile-via-uniffi.md for the 2026-06 decision to keep mobile native (SwiftUI / Compose via uniffi) rather than Tauri-2-mobile — the hardware-backed-key-storage and biometric-bound-key-release story is what mobile needs and only the native frameworks provide off the shelf. The Rust-core layer mirrors the architecture used by Bitwarden, 1Password, Signal, and Mullvad — well-trodden territory.
| Role | Primitive |
|---|---|
| Password KDF | Argon2id (m=256 MiB, t=3, p=1) |
| Symmetric AEAD | XChaCha20-Poly1305 (24-byte nonces, 256-bit keys) |
| KEM (recipient wraps) | X25519 ⊕ ML-KEM-768 hybrid |
| Signatures | Ed25519 ∧ ML-DSA-65 hybrid (both must verify) |
| Hash | BLAKE3 (general); SHA-256 (HKDF) |
| Recovery mnemonic | BIP-39, 24 words (256 bits) |
All hybrid constructions are designed so that an attacker must break both halves to compromise security. ML-KEM-768 (FIPS 203) and ML-DSA-65 (FIPS 204) are the NIST-standardized post-quantum primitives at security level 3.
For the full cryptographic specification — sufficient detail to implement an interoperable client from scratch — see docs/crypto-design.md. For the on-disk byte format, see docs/vault-format.md. For threats addressed and explicitly not addressed, see docs/threat-model.md.
A vault is a directory of files:
<vault-folder>/
vault.toml # cleartext metadata (no secrets)
identity.bundle.enc # dual-wrapped identity (master KEK + recovery KEK)
manifest.cbor.enc # encrypted, signed top-level index
contacts/ # imported public contact cards
blocks/ # one file per block (encryption + sharing unit)
trash/ # tombstoned blocks awaiting purge
A block is the unit of both encryption and sharing. A block contains 1 or more records (login, secure note, API key, SSH key, custom). Sharing a block means copying its file into a folder the recipient can access, with the recipient's per-recipient key-wrap added to the file's recipient table.
Normative specifications — the source of truth. A clean-room implementation in any language can be built from these alone, without reading the Rust source. This is verified during implementation by a Python conformance script that decrypts a published reference vault using only the spec.
| File | Purpose |
|---|---|
| docs/glossary.md | Definitions of all terms used in the specs |
| docs/threat-model.md | Adversaries, attacks, defenses, explicit non-goals |
| docs/crypto-design.md | Cryptographic constructions in spec-level detail |
| docs/vault-format.md | Byte-level on-disk format (v1) |
| docs/adr/ | Architecture decision records — ten in total |
User and contributor manual — informal companions to the specs.
| File | Purpose |
|---|---|
| docs/manual/primer/cryptography/ | A thirteen-chapter cryptography primer in plain language for curious users — what symmetric vs. asymmetric encryption is, why post-quantum hybrids matter, and how Secretary uses each idea. No prior background assumed. |
| docs/manual/hardening-security.md | User-facing guidance for pushing operational security beyond the (already strong) defaults. |
| docs/manual/contributors/differential-replay-protocol.md | The cross-language differential-replay contract used by core/tests/python/conformance.py --diff-replay and the fuzz harness. |
A coverage-guided fuzz harness for the wire-format decoders lives in
core/fuzz/. It uses cargo-fuzz on a
path-scoped nightly toolchain and ships with a single-file NiceGUI
dashboard (core/fuzz/monitor.py) for running and watching
campaigns. Cross-language differential replay through the Python
conformance script (conformance.py --diff-replay) is documented in
docs/manual/contributors/differential-replay-protocol.md.
See the core/fuzz/README.md for how to run
it and how to promote findings into durable regression KATs.
Promoted regression inputs live under
core/tests/data/fuzz_regressions/
and replay through
core/tests/fuzz_regressions.rs
under the "must not panic" contract.
AGPL 3.0. A commercial license is available for entities wanting to ship closed-source derivatives. See LICENSE.
The user-facing application and the source code are both free of charge. Only commercial closed-source derivatives require a paid license.
Repository initialized April 2026. Sub-project A — the cryptographic foundation, vault format spec, and Rust core — is in active implementation:
| Component | Status |
|---|---|
| Cryptographic design + on-disk format spec (frozen for v1) | ✅ Complete |
| Cryptographic primitives (AEAD, KDF, KEM, sig, hash, identity) | ✅ Complete, NIST KAT-pinned |
| Vault unlock (BIP-39, identity bundle, vault.toml, recovery key) | ✅ Complete (PR #1) |
| Block file format (record CBOR, header, recipients, AEAD, hybrid sig) | ✅ Complete (PR #3) |
| Manifest layer + atomic writes + high-level orchestrators | ✅ Complete (PR #5) |
golden_vault_001/ end-to-end §15 conformance fixture (full crypto) |
✅ Complete (PR #5) |
CRDT merge primitives (conflict.rs: merge_record, merge_block, clock_relation, merge_vector_clocks) + record-level tombstoned_at_ms death-clock for full-domain associativity + commutativity / associativity / idempotence proptests |
✅ Complete (PR-C) |
CRDT polish: bidirectional defensive death-clock clamp + tag-canonicalisation on LWW-clone path + clean-room Python py_merge_unknown_map for record-level unknown + 11-vector conflict_kat.json cross-language replay + well-formedness Property L proptest |
✅ Complete (PR #9) |
Coverage-guided fuzz harness (cargo-fuzz over six wire-format decoders, NiceGUI dashboard, cross-language differential-replay protocol) |
✅ Complete (PR #8); first artifacts triaged + display_name DoS-bound (PR #11); live monitor telemetry (PR #12) |
| Cryptography primer for users / contributors (13 chapters) | ✅ Complete (PR #10) |
| Hardening: side-channel review, memory hygiene audit, threat-model & format-spec doc pass | 🚧 Phase A.7, in progress |
| 2026-07-02 pre-release security audit remediation (findings #349–#370) | ✅ All 22/22 findings closed (PR #371 + both deferrals). Crash-recovery-on-open (#350) — manifest-first trash_block, open-time trash-completion sweep, and a gated repair_vault orchestrator (typed errors; spec §6.5/§7/§9). Desktop dialog path binding (#353) — native folder/file dialogs move backend-side; every path-consuming IPC command (unlock, create, contact-card export, contact import) validates its webview-supplied argument against the set of paths the user actually approved in a dialog, closing the gap where a compromised webview could pass an arbitrary path straight to the Rust backend. FFI/app projection of repair (#374) shipped 2026-07-04: repair_vault is projected onto all three FFI arms (password/recovery/device-secret) with two typed errors (VaultNeedsRepair from open, RepairRejected from a refused repair) surfaced across uniffi + PyO3, plus a desktop "repair now?" reference flow (offer repair on a needs-repair unlock, reusing the password; render the refusal detail). Informed-consent adoption of a crashed-share recipient-widening residue (the final #374 slice) shipped 2026-07-06: a preview-bound RepairPolicy::AdoptApproved path where consent binds to the exact on-disk file fingerprint and added-recipient set the user was shown, with a desktop consent dialog rendering each recipient who would gain access by name + card fingerprint; default stays fail-closed. Follow-up #384 (2026-07-04) hardened the repair-time §10 rollback gate: the baseline is keyed by the verified manifest vault_uuid, and an existing-but-unreadable baseline store fails the mutating repair closed. |
| External cryptographic audit | 🚧 Phase A.7 |
| FFI bindings (PyO3 boilerplate) | ✅ Sub-project B.1 (Python; round-trip pipeline proven, no vault crypto exposed yet) |
| FFI bindings (uniffi for Swift + Kotlin) | ✅ Sub-project B.1.1 (macOS-host Swift smoke runner) and B.1.1.1 (JVM-host Kotlin smoke runner with pinned + SHA-256-verified JNA fetch) |
| FFI bindings (vault unlock — password path) | ✅ Sub-project B.2 (open_with_password through PyO3 + uniffi via shared secretary-ffi-bridge crate; explicit close + RAII lifecycle) |
| FFI bindings (vault unlock — recovery-phrase path) | ✅ Sub-project B.3a (open_with_recovery through the same bridge; thinned 5-variant error type with §13 anti-oracle conflation preserved on both paths; UTF-8-validation seam; mnemonic input as caller-zeroizable bytes) |
| FFI bindings (vault creation + output-direction mnemonic) | ✅ Sub-project B.3b (create_vault through PyO3 + uniffi via shared secretary-ffi-bridge crate; four-field CreateVaultOutput with one-shot MnemonicOutput for the recovery phrase; OsRng + Argon2idParams::V1_DEFAULT instantiated bridge-side; path-neutral CorruptVault Display) |
| FFI bindings (folder-based vault open — password + recovery paths) | ✅ Sub-project B.4a (open_vault_with_password + open_vault_with_recovery through PyO3 + uniffi via shared secretary-ffi-bridge; folder-IO model established; 6-variant FfiVaultError; OpenVaultManifest opaque handle with BlockSummary read-only block list) |
| FFI bindings (block read) | ✅ Sub-project B.4b (read_block through PyO3 + uniffi via shared secretary-ffi-bridge; first mutation-free block-access path; hybrid Record projection with 3 new opaque handles BlockReadOutput / Record / FieldHandle; explicit expose_text() / expose_bytes() boundary for secret payload; 7-variant FfiVaultError adding BlockNotFound; bridge-internal vault_folder extension; v1 single-author only) |
| FFI bindings (block save) | ✅ Sub-project B.4c (save_block through PyO3 + uniffi via shared secretary-ffi-bridge; first mutation path with atomic write ordering per §9; 9-variant FfiVaultError adding SaveCryptoFailure; foreign-side input shape BlockInput / RecordInput / FieldInput / FieldInputValue carrying secrets through zeroize-on-drop SecretString / SecretBytes; failure invariant: bridge in-memory state byte-identical to pre-call on Err; v1 single-author only) |
| FFI bindings (block share) | ✅ Sub-project B.4d (share_block through PyO3 + uniffi via the same shared bridge; first FFI call where ContactCard values cross the boundary as canonical-CBOR bytes-in via the new OpenVaultManifest::owner_card_bytes() accessor; 13-variant FfiVaultError adding 4 typed share variants — NotAuthor / RecipientAlreadyPresent / MissingRecipientCard / CardDecodeFailure; mirrors core's single-recipient-append signature so atomicity is per-call; v1 single-author only — share-as-fork future PR). Hardened against TOFU contact-card substitution and given a verified sharing path (import_contact_card / share_block_to now projected to PyO3 + uniffi; raw share_block retained but discouraged) (#206) |
| FFI bindings (block trash + restore lifecycle pair) | ✅ Sub-project B.5 (trash_block + restore_block through PyO3 + uniffi via the same shared bridge; 15-variant FfiVaultError adding 2 typed restore-side variants — BlockUuidAlreadyLive / BlockNotInTrash; RestoreVerificationFailed folds to CorruptVault per the "data on disk doesn't match what we signed" contract; restore reads the file in trash/ whose suffix matches the signed TrashEntry.tombstoned_at_ms (#205 — not the largest suffix, which is attacker-forgeable), full-decrypts + hybrid-verifies AND checks the signed TrashEntry.fingerprint content commitment (#293 — rejects an in-place overwrite of the suffix-matching trash file with an older owner-signed copy; legacy commitment-less entries fall back to suffix-equality) before any manifest mutation, resolves recipient_fingerprint → contact_uuid by scanning contacts/*.card, then renames trash/ → blocks/ and purges older copies best-effort; the per-block vector clock is preserved verbatim from the file header for sync correctness; new docs/vault-format.md §7.1 normative sequence; v1 owner-as-author only) |
| FFI bindings (block purge + empty-trash — completing the trash lifecycle) | ✅ (#399, 2026-07-08): permanent removal of a trashed block's local ciphertext — purge_block + empty_trash through PyO3 + uniffi via the same shared bridge. Manifest-first (mirrors trash_block): mark the TrashEntry purged via a new additive-optional purged_at_ms (no manifest_version bump — same unknown-map forward-compat as fingerprint) as the commit point, then best-effort unlink the trash/ copies. One erasure mechanism, no overwrite — FS secure-erase is unachievable on SSD/CoW/snapshots; for an owner-only block the unlink is the crypto-shred, since the wrapped Block Content Key exists only inside that file. Owner-only vs shared is classified from the §6.2 recipient table for honest reporting only (a shared block's recipients keep decryptable copies — purge is local cleanup). empty_trash is a single-resign batch. 1 new typed FfiVaultError::BlockPurged (restore of a purged block fails fast before any trash scan; the marker lives in the signed manifest, so it's unforgeable) — threaded through every binding + the Swift/Kotlin conformance harnesses. An open-time purge-cleanup sweep (gated on "not live in manifest.blocks", so a concurrent restore wins safely) propagates a purge across the owner's devices via manifest file sync. New docs/vault-format.md §7.2; clean-room conformance.py §P + cross-language purge KAT vectors (Swift 38/38 · Kotlin 38/38 · pyo3 118/118). Conflict-copy trash-merge-monotonicity ✅ shipped in #401 (2026-07-08, Core-only): the C-layer sync merge now unions TrashEntry records across conflict copies and merges purged_at_ms monotonically (Some-if-either / max-millis / never un-purges), with purge-terminal live-vs-trash resolution (a purged block beats a concurrent restore) — so a purge marker survives a conflict-copy merge; normative docs/crypto-design.md §11.6 + a trash_merge_kat.json clean-room witness (conformance.py §4b). Retention auto-purge ✅ shipped in #402 (2026-07-09): auto_purge_expired permanently purges every trashed block older than a 90-day-default retention window via the same purge_batch_commit batch path empty_trash uses, plus a pure expired_trash_entries preview a caller can show before committing. Caller-invoked only — not automatic on open, no scheduler. FFI projection ✅ shipped 2026-07-09 — auto_purge_expired + expired_trash_entries + DEFAULT_RETENTION_WINDOW_MS through the bridge → uniffi (Swift/Kotlin) + pyo3, reusing empty_trash's error surface (no new FfiVaultError variant); Swift/Kotlin conformance + pyo3 pytest green. Desktop retention/purge UX ✅ shipped 2026-07-10 (see the desktop retention row below); iOS/Android retention/purge UIs remain deferred. |
| FFI bindings (record edit — append / edit / tombstone / resurrect) | ✅ (2026-06-13): the four record-edit bridge primitives — shipped bridge-only in desktop D.1.4 / D.1.5 — are now projected onto uniffi + pyo3 (the same bridge-only→projected pattern as the sync surface #187), the prerequisite FFI for the native-iOS record-CRUD UI. A new RecordContent foreign input type (reusing the zeroize-typed FieldInput / FieldInputValue from save_block) carries the editable delta; the bridge owns the CRDT semantics (untouched per-field clocks + tombstoned_at_ms + all unknown maps preserved). Per-field-clock preservation is proven at the binding boundary in Swift + Kotlin smoke (via FieldHandle.device_uuid()) and pyo3 pytest. No new FfiVaultError variant (BlockNotFound / RecordNotFound already existed) — both conformance harnesses stay 27/27; no core / on-disk-format / UDL-error / KAT change. (at this point read_block surfaced tombstoned records unfiltered via the per-record tombstone() flag; as of the 2026-06-14 include_deleted gate it withholds them unless the caller opts in — see the iOS include_deleted row below.) |
| FFI bindings (block CRUD — create / rename block + move record) | ✅ (uniffi 2026-06-19, pyo3 2026-06-20): three bridge primitives — create_block, rename_block (changes only the block name, preserving every record + all unknown maps), and move_record (a faithful move — copy a live record into a target block under a caller-minted fresh record_uuid, preserving created_at_ms / per-field clocks / values / all unknown, then tombstone the source; copy-before-delete so a missing target leaves the source live) — projected onto uniffi + pyo3 via the shared bridge. The caller mints all UUIDs (void returns); the same-block (source ≠ target) and uuid-length checks live at each binding wrapper (uniffi VaultError::InvalidArgument, pyo3 ValueError) — the bridge trusts its caller on both. No new FfiVaultError variant (BlockNotFound / RecordNotFound already existed) — conformance stays 27/27; no core / on-disk-format / UDL-error / KAT change. Proven by cargo (bridge + uniffi), Swift/Kotlin conformance + smoke, and pyo3 test_block_crud.py. |
| FFI bindings (sync surface — status / vault / commit-decisions + conflict-resolution DTOs) | ✅ #187 (2026-06-09): sync_status, sync_vault, sync_commit_decisions + VetoDto/CollisionDto/VetoDecisionDto/SyncOutcomeDto projected onto both PyO3 + uniffi; each takes an explicit state_dir so mobile passes its sandbox path; Python pytest ConflictsPending→commit_decisions→MergedClean round-trip + Swift/Kotlin SmokeSync parity-smokes green |
| Per-device wrap slot (ADR 0009) | ✅ (2026-06-10): a third unlock path enabling hardware-backed/biometric device unlock — devices/<uuid>.wrap, file_kind 0x0004; HKDF device KEK; enroll/open/revoke; conformance KAT. FFI projection (#201) shipped 2026-06-11: add_device_slot / open_with_device_secret / remove_device_slot folder-in ops through PyO3 + uniffi via the shared bridge (a new core Unlocker::DeviceSecret arm makes the device open a first-class full vault open); one-shot DeviceSecretOutput handle; 3 new typed FfiVaultError variants (DeviceSlotNotFound / WrongDeviceSecretOrCorrupt / DeviceUuidMismatch) threaded through every binding + the Swift/Kotlin conformance harnesses; cross-language KAT + clean-room conformance.py coverage. |
| iOS device unlock (B.3) | ✅ (2026-06-11): Secure-Enclave-backed, biometric-gated release of the per-device secret — pure orchestration (DeviceUnlockCoordinator over three injected ports) in ios/SecretaryDeviceUnlock/, host-tested via swift test; iOS adapters in SecretaryKit/DeviceUnlock/ (real uniffi port, non-exportable SE P-256 conformer behind a biometric SecAccessControl, Keychain metadata store). The SE conformer is compile-verified on the simulator with a fake enclave; real biometric release on a device is the #202 follow-up. |
| iOS app walking-skeleton | ✅ (2026-06-11): SwiftUI SecretaryApp/ (XcodeGen) drives the full enroll / unlock / disenroll flow through the real DeviceUnlockCoordinator; built via ios/scripts/build-app.sh (also staged by run-ios-tests.sh). SecretaryDeviceUnlockUI provides the host-tested DeviceUnlockViewModel. On-device biometric proof (#202) ✅ verified on an iPhone 13 Pro Max (2026-06-11): the real Secure Enclave + Face ID released the device secret and opened the vault (vault_uuid matched the pinned fixture); biometric cancel / non-match surface in LAError (userCancel) → typed userCancelled, never mislabelled as tamper. |
| iOS app — password/recovery unlock + read-only browse | ✅ (2026-06-12): a new FFI-free SecretaryVaultAccess package (ports + pure models + host-tested UnlockViewModel / VaultBrowseViewModel) lets the app open the staged vault by password or 24-word recovery phrase, then browse blocks → records read-only with reveal-on-demand secret fields; real UniffiVaultOpenPort / UniffiVaultSession adapters in SecretaryKit/VaultAccess/ + a simulator integration test against golden_vault_001; the error mapping preserves the core's anti-oracle conflation (a wrong credential is indistinguishable from corruption); reveals are dropped + the session wiped on background. 100% Swift — no Rust / FFI-surface change. |
| iOS app — vault selection | ✅ (2026-06-12): the app now opens a user-selected vault folder (system .fileImporter) and remembers it across launches via a persisted security-scoped bookmark; the prefilled demo password is gone (the bundled golden vault stays as an explicit opt-in). A pure VaultLocationStore port + host-tested VaultSelectionViewModel (empty/located/unavailable state machine) live in SecretaryVaultAccess; the real BookmarkVaultLocationStore (bookmark persistence + scoped access) is in SecretaryKit, with a simulator test that opens golden_vault_001 through a resolved bookmark. The security scope is held for the whole session (lazy block reads) and released on lock; stale/unresolvable bookmarks surface as typed errors (no silent fallback). 100% Swift — no Rust / FFI-surface change. On-device smoke ✅ verified on an iPhone 13 Pro Max (2026-06-12): pick → persisted bookmark → relaunch reopens → unlock by password and recovery → browse → reveal → background re-lock. |
| iOS app — record CRUD | ✅ (2026-06-13): on a selected, unlocked vault the app can now add a new record (type + tags + text/bytes fields), edit an existing record's full content (add/remove/rename fields, switch text↔bytes, edit values, change type+tags — CRDT-correct via the bridge edit primitives), soft-delete (tombstone) and restore (resurrect) a record. Deleted records are hidden by default behind a "Show deleted" toggle (at ship time, filtered client-side in Swift; superseded 2026-06-14 by the Rust include_deleted gate — see below). The per-field CRDT modifier clock uses a stable per-(install, vault) device UUID persisted in Application Support. Verified by host-tested view models + a simulator XCTest add/edit/delete/restore round-trip (incl. a cross-open durability check) against a temp copy of golden_vault_001. 100% Swift — no Rust / on-disk-format / FFI-surface change (record-edit bridge primitives were projected in the preceding FFI slice). Biometric re-auth before a write shipped 2026-06-21 (see below). |
| FFI bindings (folder-writing vault creation) | ✅ (2026-06-13): create_vault_in_folder through PyO3 + uniffi via the shared secretary-ffi-bridge — the folder-writing sibling of B.3b's bytes-based create_vault. It delegates to core::vault::create_vault to write all four canonical files (so the result opens through the folder-based open_vault_with_password, unlike B.3b's identity-level bytes) and returns just the one-shot MnemonicOutput (no auto-open — the caller re-opens with the password to browse, mirroring desktop). Requires an existing empty dir; a new typed FfiVaultError::VaultFolderNotEmpty distinguishes "not empty" from a wrong path or corruption, threaded through every binding + the Swift/Kotlin conformance harnesses (both stay 27/27). OsRng + Argon2idParams::V1_DEFAULT hardcoded bridge-side. Slice 1 of the iOS create/import feature; the native create-wizard UI is Slice 2. No core / on-disk-format change. |
| iOS app — vault create / import | ✅ (2026-06-14): the selection screen now branches Create new vault / Import existing vault. Create is a 3-step wizard (pick a parent folder + name → master password + confirm + display name → 24-word recovery-phrase screen with an "I wrote it down" gate) that mkdir's a fresh subfolder and writes the vault via createVaultInFolder, then returns to select so the user re-enters the password to open (desktop D.1.3 parity — no auto-open). Import adds a crypto-free vault.toml shape probe so a non-vault folder is rejected before any password entry. Pure host-tested VaultProvisioningViewModel + helpers (validateVaultName / passwordsMatch / groupMnemonic) over VaultCreatePort / VaultShapeProbe in SecretaryVaultAccess; real UniffiVaultCreatePort (security-scoped mkdir + bookmark) / FileManagerVaultShapeProbe in SecretaryKit; a simulator create→open round-trip in a tempdir. 100% Swift — no Rust / on-disk-format / FFI-surface change. Follow-up #224 (route VMs as @StateObject). |
iOS app — include_deleted Rust gate |
✅ (2026-06-14): record-level tombstone visibility moved off the Swift client into the shared bridge read_block(include_deleted) — a withheld record builds no FieldHandle, so deleted secrets never cross the FFI seam. The "Show deleted" toggle now re-reads through the gate (desktop D.1.5 parity) instead of filtering cached records client-side, so the client never holds withheld data. Single source of truth across iOS + desktop + Python: desktop dropped its duplicate project_block_detail filter. Threaded through the UDL + uniffi + pyo3 + every Swift/Kotlin/Python harness (both conformance harnesses stay 27/27, no KAT change). No core-format / crypto change. |
| iOS app — responsive unlock/create (KDF off the main actor) | ✅ (2026-06-14): the CPU-heavy Argon2id open/create no longer runs on the main actor — VaultOpenPort / VaultCreatePort are now async and the real SecretaryKit adapters offload the synchronous FFI call through a shared runOffMainActor (withCheckedThrowingContinuation + a user-initiated global queue), so the @MainActor unlock / create view-models suspend rather than block while the KDF runs and the UI stays responsive. Device unlock is unaffected (HKDF, already fast). Proven by host-tested responsiveness tests (a SuspensionGate rendezvous observes the in-flight .busy / pre-.mnemonic state while the port is parked). 100% Swift — no Rust / on-disk-format / FFI-surface change. |
| iOS app — sync orchestration core (C.3 slice 1) | ✅ (2026-06-15): the iOS side can now run one sync pass and carry a tombstone-veto conflict to resolution, entirely in pure host-tested Swift over the existing sync_status / sync_vault / sync_commit_decisions uniffi surface (#187). A new FFI-free layer in SecretaryVaultAccess adds metadata-only value types, VaultSyncPort, a dedicated VaultSyncError (separate from VaultAccessError, anti-oracle conflation preserved), and a SyncCoordinator actor that threads the two-call inspect→commit round-trip (the manifest_hash freshness token held privately; password passed per call, never stored). The real UniffiVaultSyncPort in SecretaryKit offloads the Argon2id-bearing sync / commitDecisions off the main actor via runOffMainActor (status runs inline). Host-tested coordinator round-trip + simulator off-main-actor responsiveness tests (sync and commit paths). 100% Swift — no Rust / FFI-surface / on-disk-format change. |
| iOS app — folder-change detection (C.3 slice 2) | ✅ (2026-06-15): an advisory, detect-only "remote changes detected" signal for an open vault's folder — a debounced, foreground-gated pendingChanges flag a later UI slice surfaces as a sync badge. Detect-only by necessity: sync_vault needs the password (full Argon2id), which the app drops after unlock, so a file event can't silently run a pass — it sets the flag, and acting on it (re-prompt / sync-at-unlock) is slice 3. A pure FFI-free core in SecretaryVaultAccess — a FolderChangeDetector trailing-debounce reducer (no real clock; caller-supplied MonotonicInstants + explicit flush), a ChangeDetectionMonitor (@MainActor) coordinating two injected ports (FolderWatchPort + FlushScheduler), plus an optional self-write mute hook — is fully host-tested via fakes; the real PresenterFolderWatch (NSFilePresenter, callbacks confined to the main queue) + DispatchFlushScheduler conformers in SecretaryKit are covered by one simulator smoke test (a coordinated write raises the flag). Foreground-only per ADR 0003. 100% Swift — no Rust / FFI-surface / on-disk-format change. Android Compose render shipped 2026-06-16 (C.3 slice 5). |
| iOS app — sync UI (C.3 slice 3) | ✅ (2026-06-15): the slice-1 coordinator + slice-2 monitor are now user-visible — a sync-status badge on the browse screen, an opportunistic sync-at-unlock, an on-demand re-prompt sync, and a metadata-only conflict-resolution sheet mirroring desktop D.1.15 (per-record Keep mine / Accept delete, default Keep mine, read-only auto-merged-collision disclosure). Two triggers funnel into one interactive resolution path so the password is never held across a modal at unlock (a conflict detected at unlock just flips the badge to "review needed"; resolution re-prompts). Decisions made this slice: state dir = app-sandbox Application Support (secretary/sync/); password policy = sync-at-unlock + re-prompt (sync needs the full password, which the app drops after unlock, and the device-secret path can't feed it without an FFI change). A pure host-tested core in SecretaryVaultAccess/SecretaryVaultAccessUI (a WallClock port, SyncBadgeState derivation, a SyncMonitorHook seam, the VaultSyncViewModel) + thin SwiftUI views and SecretaryKit conformers (SystemWallClock, MonitorSyncHook, defaultSyncStateDir, makeVaultSync); self-write mute wired around sync's own commits. 100% Swift — no Rust / FFI-surface / on-disk-format change. |
| iOS app — biometric re-auth before a write | ✅ (2026-06-21): every mutating vault write (add/edit/delete/restore record, move record, create/rename block) now asks for a Face ID / Touch ID re-auth first, gated by a grace window (ReauthWindow.v1Default = 30s since the last successful auth — one prompt covers a burst of edits). Re-auth reuses the same Secure-Enclave key-release as device unlock (DeviceSecretEnclave.release, the released secret zeroized + discarded — strictly stronger than a bare LAContext.evaluatePolicy); the gate engages only when device-unlock is enrolled (enclave.isEnrolled), so a non-enrolled session writes exactly as before. A pure FFI-free core in SecretaryVaultAccess — the WriteReauthGate / BiometricAuthorizer ports + the pure needsReauth policy — with a @MainActor GraceWindowReauthGate holder; both @MainActor view models await the gate before the write (a refused biometric surfaces .reauthFailed and leaves any open dialog/sheet open, writing nothing). Real EnclaveBiometricAuthorizer in SecretaryKit. Host-tested gate/policy + VM tests + a simulator round-trip; on-device Face ID a manual checklist item (#202 parity). 100% Swift — no Rust / FFI-surface / on-disk-format change. |
| iOS app — biometric device-unlock → browse (#284) | ✅ (2026-07-01): the Unlock screen gains an "Unlock with Face ID" button when this device is enrolled — it releases the device secret from the Secure Enclave and opens the vault via the same B.2 open_with_device_secret manifest verify-before-decrypt as password/recovery, landing in browse. The write-reauth grace window is now seeded at the biometric-unlock instant, so the first write after a biometric open is free within the window (closes #284); password/recovery opens still seed nothing. Password mode gains a "Remember this device" checkbox that enrolls the device after a successful password open (non-fatal, offloaded off the main actor). Non-cancel biometric failures show a typed message; a cancel returns to Unlock silently (#341). 100% Swift — no Rust / FFI-surface / on-disk-format change. |
| iOS app — per-vault-keyed biometric enrollment (#347) | ✅ (2026-07-02): the "Unlock with Face ID" button (and the whole device-unlock enrollment) is now scoped to the vault being opened instead of device-global, so a multi-vault user never gets a doomed biometric prompt for a vault this device isn't enrolled for. The Secure-Enclave key and enrollment-metadata Keychain entries are namespaced by vaultKey = SHA-256(vault path) (a new pure derivation in the FFI-free SecretaryDeviceUnlock package + a makePerVaultDeviceUnlock factory in SecretaryKit), mirroring Android's cloudVaultKey. Per-vault-ness lives in the storage keys, not DeviceEnrollment, so coordinator.isEnrolled is correct-by-construction and the cross-vault prompt is unreachable; the pre-prompt vaultId guard and post-open UUID check remain as defense-in-depth. The write-reauth grace gate (#284) is now per-vault too (armed only for the enrolled vault — an improvement, not a regression). Accepted: path-hash instability on vault relocation (degrades to a silent re-enroll; password unlock always works); no migration of pre-release device-global items. 100% Swift — no Rust / FFI-surface / on-disk-format change. |
| Android app — biometric re-auth before a write | ✅ (2026-06-21): every mutating vault write (add/edit/delete/restore record, move record, create/rename block) now requires a biometric presence proof first, gated by a 30 s grace window (one prompt covers a burst of edits). The gate reuses the KeystoreDeviceSecretEnclave.release path that backs device unlock (BiometricPrompt via CryptoObject — a stronger proof than a bare BiometricPrompt.authenticate), and engages only when device-unlock is enrolled; a non-enrolled / password-only session writes exactly as before. Pure Kotlin in :vault-access (WriteReauthGate / BiometricAuthorizer ports + GraceWindowReauthGate + needsReauth policy) + real CoordinatorBiometricAuthorizer in :vault-access; VaultBrowseModel + RecordEditModel call guardedWrite/commitThenReload with a typed reason before every mutation; a cancelled prompt is silent, any other failure surfaces VaultBrowseError.ReauthFailed and leaves the open dialog intact, writing nothing. Gate seeded at unlock; reset on lock. Host-tested (policy + gate + VM) + manual on-device checklist. Android-only; no core / ffi / on-disk-format change. Deferred: configurable/persisted grace-window setting; presence proof for password-only sessions with no device-secret enrollment. |
| Android (C.3) | pure Kotlin sync orchestration core — android/vault-access, a host-tested kotlin("jvm") Gradle module (the repo's first Gradle project) mirroring iOS slice 1: VaultSyncPort, VaultSyncError, SyncCoordinator, metadata-only value types (slice 1). Slice 2a (2026-06-15): the real UniffiVaultSyncPort landed in a new :kit Android-library module over the generated uniffi bindings + arm64 jniLibs (cross-built via cargo-ndk); host- and build-verified (the arm64 .so packs into the release AAR). Slice 2b (2026-06-16): the on-device round-trip is now proven — an instrumented test drives golden_vault_001 through status / sync over the real native .so via UniffiVaultSyncPort + SyncCoordinator on the Medium_Phone_API_36.1 emulator. Slice 3 (2026-06-16): folder-change detection — an advisory, detect-only debounced + foreground-gated pendingChanges signal, mirroring iOS slice 2. A host-tested FolderChangeDetector reducer + ChangeDetectionMonitor over injected ports in :vault-access; the real :kit adapter is a single non-recursive FileObserver on the vault root (sufficient because manifest.cbor.enc is rewritten on every committed advance) + a main-Looper scheduler, with one emulator smoke. Foreground-only (WorkManager background deferred). Slice 5 (2026-06-16): Compose sync render shipped — see the slice-5 row below. Slice 6 (2026-06-17): the first runnable :app walking skeleton shipped (Compose unlock → silent sync → badge over the real makeVaultSync lifecycle, bundled golden_vault_001 demo vault, on-device makeVaultSync smoke) — sync-only at the time; see the slice-6 row below. Slice 7 (2026-06-17): vault open/browse shipped — the app now opens a vault (real open_vault_with_password, Argon2id on IO inside the port) and shows a metadata-only Compose BrowseScreen (block list → record titles/types/tags/field-names); see the slice-7 row below. Slice 8 (2026-06-17): reveal-on-tap shipped — tapping a field exposes its plaintext via the FFI expose_* (the first Android slice where a secret value crosses the adapter), with 30s auto-hide, tap-to-hide, and lock-on-background; see the slice-8 row below. Slice 9 (2026-06-18): soft-delete lifecycle shipped — the first Android slice that writes to a vault (a show-deleted toggle + per-row delete/restore over the existing uniffi tombstoneRecord/resurrectRecord, a file-backed device-UUID under noBackupFilesDir, writes serialized under the same session lock/wiped guard as reads); see the slice-9 row below. Slice 10 (2026-06-18): record add + edit shipped — full RecordEditForm (free-text record type, editable tags, per-field Text/Bytes kind picker for text + bytes-as-hex fields, add/remove fields, per-row edit button), RecordEditModel mirroring iOS RecordEditViewModel, built on the slice-9 write infra (appendRecord/editRecord); see the slice-10 row below. Sync-on-browse (2026-06-18): the sync badge + sync-at-unlock re-integrated onto the browse screen — a new app-level BrowseWithSyncScreen stacks the (untouched) SyncScreen above BrowseScreen, so an unlocked user sees a live sync badge and the interactive sync/conflict flow on the same screen they browse/edit on; sync-at-unlock runs in the background off the render path via a copy-then-zeroize launchSyncAtUnlock helper; monitor lifecycle bound to the Browse composition (start on entry, stop on dispose, failed start non-fatal); mirrors iOS's unified VaultBrowseScreen; known cost: both platforms run a separate Argon2id for the sync pass (the sync coordinator opens the vault with the password per call on both platforms); the Android-specific delta is that Android cannot reuse the open session even for the vault UUID (iOS's makeVaultSync(session:) reads it from the session; Android provisions it via goldenVaultUuid); sync-pass Argon2id mitigated by running it in the background on both; see the sync-on-browse row below. Biometric write re-auth (2026-06-21): every mutating vault write now requires a biometric presence proof first (30 s grace window, gate active iff device-secret is enrolled); see the biometric re-auth row above. No FFI-surface / on-disk-format change. |
| Android app — sync-UI model (C.3 slice 4) | ✅ (2026-06-16): the host-tested heart of the Android sync UI, mirroring iOS slice 3 minus rendering. A pure FFI-free layer in :vault-access — syncBadgeState (5-state precedence), VaultSyncModel (StateFlow surface; two triggers — silent syncAtUnlock + interactive runInteractivePass/resolve — converging on one resolution path; metadata-only conflict surface; typed errors that keep the conflict context for retry; acknowledge only on clean arms), WallClock / SyncMonitorHook seams, and collectDecisions/decisionsComplete (per-record default "Keep mine") — all JUnit-5 host-tested via fakes. Real :kit wiring: SystemWallClock, MonitorSyncHook (wraps ChangeDetectionMonitor), and a makeVaultSync factory. The Compose render (badge, password sheet, conflict sheet) is slice 5. 100% additive Kotlin — no Rust / FFI-surface / on-disk-format change. |
| Android app — Compose sync render (C.3 slice 5) | ✅ (2026-06-16): a new FFI-free :sync-ui Compose Android library module (namespace org.secretary.sync.ui, depends on :vault-access only) renders the slice-4 VaultSyncModel. VaultSyncViewModel is a thin androidx.lifecycle.ViewModel bridge re-exposing the model's StateFlows and owning the password-sheet visibility flag (host JUnit5 tested). Three hoisted Compose surfaces: SyncBadge (5-state badge with spinner while syncing and a relative "synced N ago" label), SyncPasswordSheet (ModalBottomSheet; password held in transient Compose state only, never persisted, stays open on error), and ConflictResolutionSheet (metadata-only per-record Keep mine / Accept delete, default Keep mine, read-only auto-merged-collision summary — mirrors desktop D.1.15); plus SyncScreen wiring them together. Pure render helpers (relativeSyncedLabel, badgeLabel, badgeIcon, syncErrorLabel) are host-tested. Icons from material-icons-core (no heavy extended dep). The interactive password is held only in SyncScreen's transient Compose state and zeroized (fill(0)) on every terminal path (including retry); never stored on the VM or model. First Compose UI-test harness on Android: 15 instrumented Compose UI tests on the emulator (fake-backed VaultSyncModel — no native .so needed), plus host JUnit5 for helpers and the ViewModel. Compose BOM bumped to 2025.05.00 + Espresso forced to 3.7.0 for API-36 emulator compat. App lifecycle wiring (real makeVaultSync into an unlock/lock flow) shipped in the :app module (slice 6). 100% additive Kotlin — no Rust / FFI-surface / on-disk-format change. |
Android app — :app walking skeleton (C.3 slice 6) |
✅ (2026-06-17): the first runnable Android app — a Compose :app module hosting the slice-5 SyncScreen over the real makeVaultSync lifecycle. A minimal unlock screen takes the vault password → builds makeVaultSync on the main thread → runs a silent syncAtUnlock → routes to SyncScreen (badge + password/conflict sheets); monitor.start()/stop() bound to the Sync screen's composition; FLAG_SECURE set. Bundles a writable copy of golden_vault_001 (staged from core/tests/data) as the demo vault. Proven by an on-device instrumented smoke (MakeVaultSyncSmokeTest: real .so, happy path reaches Synced, wrong password surfaces an error). Sync-only at ship — record browse/edit followed in slice 7. 100% additive Kotlin — no core / ffi / iOS / on-disk-format change. |
| Android app — vault open/browse (C.3 slice 7) | ✅ (2026-06-17): the first Android slice that opens a vault. Unlock → real open_vault_with_password (Argon2id offloaded to IO dispatcher inside the port) → a metadata-only Compose BrowseScreen: block list → selected block's record titles, types, tags, and field-names. No secret value is ever read — RecordSummaryView has no value field; the adapter never calls expose_* and closes the decrypted BlockReadOutput immediately. A new FFI-free :browse-ui module (parallel to :sync-ui) houses the VaultBrowseViewModel + Compose render. Lock-on-background wipes the session (returns to Unlock; re-entry re-opens). The prior sync flow (:sync-ui, makeVaultSync, SyncScreen) stays in the repo — sync-badge re-integration onto BrowseScreen is deferred. The open/read uniffi surface already existed from iOS; this is a pure Kotlin-port + Compose-UI slice — no core / ffi / ios / on-disk-format change. New :vault-access seams (VaultOpenPort / VaultSession), :kit adapters (UniffiVaultOpenPort / UniffiVaultSession), and :browse-ui all host-tested. Proven on-device by OpenBrowseSmokeTest (2 cases) + existing MakeVaultSyncSmokeTest (2 cases), 4/4 on Medium_Phone_API_36.1. Reveal-on-tap deferred. |
| Android app — reveal-on-tap (C.3 slice 8) | ✅ (2026-06-17): the first Android slice where a secret value crosses the adapter. Browsing stays metadata-only until the user taps a field; then the retained FieldHandle materializes that one value on demand via expose_text / expose_bytes. Mirrors the proven iOS reveal architecture: UniffiVaultSession now retains each decrypted BlockReadOutput so per-field reveal closures stay valid until wipe() (which zeroizes blocks → manifest → identity, in that order); the only expose_* call sites in the codebase are inside that one reveal lambda. A revealed value auto-hides after RevealPolicy.autoHideSeconds = 30 (Compose-driven, injectable for tests), hides on tap, and is dropped with the whole session on background (the slice-7 lock-on-background). VaultBrowseModel owns a revealed map keyed recordUuidHex/fieldName, cleared on every reload/lock; reveal folds any unexpected throwable to a typed error rather than crashing the UI. Host-tested throughout (:vault-access, :kit, :browse-ui), plus a new instrumented Compose UI test (tap-reveal / tap-hide / auto-hide) and an on-device smoke that reveals the golden vault's password field and asserts the real .so yields hunter2. :browse-ui stays FFI-free. Pure Kotlin-port + Compose-UI slice — no core / ffi / ios / on-disk-format change. |
| Android app — soft-delete lifecycle (C.3 slice 9) | ✅ (2026-06-18): the first Android slice that writes to a vault. A Show deleted toggle + per-row Delete (tombstone) / Restore (resurrect), mirroring iOS browse parity (minus field editing, deferred to slice 10). Underneath sits the write infrastructure every later write reuses: a file-backed FileDeviceUuidStore (a non-secret 16-byte per-(install, vault) CRDT fingerprint via SecureRandom, persisted under noBackupFilesDir so a restored backup can't clone it, resolved once + cached per session) and the session write seam — UniffiVaultSession.tombstoneRecord / resurrectRecord project the existing uniffi write surface, serialized under the same sessionLock + wiped guard as reads (a write racing the lock-on-background wipe() can't touch zeroized handles). Toggling re-reads the block with includeDeleted (the Rust gate decides what's returned; the client never filters tombstones); a failed write surfaces a typed error and leaves the visible list intact (re-read on success only); new typed RecordNotFound / SaveCryptoFailure errors. Host-tested (:vault-access device-uuid store + model delete/restore/toggle, :kit error mapping), an instrumented Compose test (toggle + delete + restore), and an on-device round-trip smoke proving the real .so save-tail: delete → gone from live view → show-deleted shows it tombstoned → restore → live again. Connected 4/4 (:browse-ui) + 6/6 (:app) on Medium_Phone_API_36.1. Pure Kotlin-port slice — no core / ffi / ios / on-disk-format change. |
| Android app — record add + edit (C.3 slice 10) | ✅ (2026-06-18): full record add and edit on Android. A Compose RecordEditForm (free-text record type, editable tags, per-field Text/Bytes kind picker for text + bytes-as-hex fields, add/remove fields, per-row edit button in BrowseScreen) backed by RecordEditModel in :browse-ui — mirroring iOS RecordEditViewModel (add/edit/loadFailed modes, setFieldName/setFieldKind/setFieldRawText, addField/removeField, addTag/setTag/removeTag). RecordEditModel drives :kit's VaultSession.appendRecord / editRecord, which build a RecordContent via toFfi and call the existing uniffi write surface — the same sessionLock + wiped guard as slice-9. hexToBytesPublic bridges the hex-parse from :browse-ui to :kit (the internal hexToBytes stays internal). Host-tested throughout (:vault-access input types + parseHex, :browse-ui model add/edit/loadFailed, :kit real writers + toFfi); instrumented Compose UI tests for the add and edit flows (RecordEditFormTest 2/2); on-device round-trip smoke: append then edit a record via the real .so and assert the committed content survives a close/reopen (OpenBrowseSmokeTest 6/6, MakeVaultSyncSmokeTest 2/2, BrowseScreenSoftDeleteTest 2/2, BrowseScreenRevealTest 2/2 all green). Pure Kotlin-port + Compose-UI slice — no core / ffi / ios / on-disk-format change. |
| Android app — sync badge + sync-at-unlock on the browse screen (C.3 sync-on-browse) | ✅ (2026-06-18): the sync badge + sync-at-unlock re-integrated onto the browse screen — a new app-level BrowseWithSyncScreen stacks the (untouched) SyncScreen above BrowseScreen, so an unlocked user sees a live sync badge and the interactive sync/conflict flow on the same screen they browse/edit on. AppRoot's Browse route carries a BrowseSession (browse VM + sync VM + monitor); sync-at-unlock runs in the background off the render path (via a copy-then-zeroize launchSyncAtUnlock helper). Monitor lifecycle bound to the Browse composition (start on entry, stop on dispose; failed start non-fatal); browse session still lock()-wiped on dispose. Known accepted cost: both platforms run a separate Argon2id for the sync pass; the Android-specific delta is that Android cannot reuse the open session even for the vault UUID (iOS's makeVaultSync(session:) reads it from the session; Android provisions it via goldenVaultUuid); sync-pass Argon2id mitigated by background execution; restructuring :kit to share the session is out of scope for this slice. Mirrors iOS's unified VaultBrowseScreen. Android-only; no core / ffi / on-disk-format change. |
| Android app — recovery-phrase open (C.3 recovery-open) | ✅ (2026-06-19): a second unlock credential — the 24-word BIP-39 recovery phrase — alongside the password open, both reaching the unified BrowseWithSyncScreen. A sealed UnlockCredential (Password/Recovery) threads the credential through a pure openWithCredential dispatch; :kit wraps the already-generated openVaultWithRecovery. Because Android sync is password-keyed, a recovery-opened session shows a status-only sync badge (no auto-sync pass) and syncs manually via the badge re-prompt — mirroring iOS's optional-password onUnlocked. New typed errors WrongRecoveryOrCorrupt (conflated, anti-oracle §13) + InvalidRecoveryPhrase. Host-tested normalize/dispatch/mapping + an on-device recovery-open smoke over the real .so. Android-only; no core / ffi / on-disk-format change. |
| Android app — device-secret open (C.3 device-open slice 1) | ✅ (2026-06-19): pure DeviceUnlockCoordinator + ports + :kit FFI adapter, proven against the real .so with a fake in-memory enclave; real biometric Keystore enclave + UnlockScreen toggle = slice 2. Android-only; no core / ffi / on-disk-format change. |
| Android app — real biometric device open (C.3 device-open slice 2) | ✅ (2026-06-19): KeystoreDeviceSecretEnclave (AES-256-GCM Android Keystore key; PRODUCTION config = auth-required + StrongBox-best-effort + invalidatedByBiometricEnrollment; release gated by BiometricPrompt via CryptoObject) + FileDeviceEnrollmentMetadataStore (:kit, host-tested) + BiometricPromptGate + pure mapBiometricError (:app) + MainActivity migrated from ComponentActivity → FragmentActivity (required by androidx.biometric) + UnlockScreen gains a "Remember this device with biometrics" checkbox and "Unlock with biometrics" button + AppRoot wires enroll-on-password-unlock-with-remember and the biometric-open → BrowseWithSyncScreen path. Automated gates green (host suites, instrumented Keystore round-trip + UnlockScreen UI tests on emulator-5554, slice-1 device-secret connected smoke regression). Real-biometric on-device proof verified on an NX809J (Android 16, 2026-06-19): enrol → kill → "Unlock with biometrics" released the device secret behind a real BiometricPrompt and opened the vault to BrowseWithSyncScreen, and a cancelled prompt returned cleanly to the unlock screen (the Android analogue of iOS's #202 Face ID proof). Android-only; no core / ffi / on-disk-format change. |
| Android app — Device-management Settings (C.3 device-settings) | ✅ (2026-06-19): a "Device settings" screen reachable from the Browse screen showing this device's biometric-enrollment status; lets the user enroll (with a vault-password re-prompt) or disenroll this device. Android-only; no core / ffi / on-disk-format change. |
| Android app — block-CRUD UI affordance | ✅ (2026-06-20): Compose dialogs over the browse screen for create_block (new block), rename_block (per-block rename), and move_record (per-record move to another block via a picker) — wired over the existing uniffi session. Host-tested pure model + on-device create→move→read-back→tombstone round-trip. Android-only; no core / ffi / on-disk-format change. |
| Android app — vault provisioning UI (cloud-drive epic, slice 4) | ✅ (2026-06-28): the create-vault wizard + vault-selection entry screen + AppRoot routing, over the slice-1 VaultCreatePort and slice-2 VaultLocationStore. Two pure host-tested view models in :vault-access — VaultSelectionViewModel (empty / located / unavailable, stale-permission aware) and VaultProvisioningViewModel (folder → credentials → mnemonic → done; persist-before-reveal so a crash mid-flow leaves an openable+remembered vault; re-entrancy-guarded; the recovery-phrase ByteArray zeroized on ack/cancel) — plus pure validateVaultName / groupMnemonic helpers; two Compose screens (VaultSelectionScreen, CreateVaultWizardScreen) + a SAF OpenDocumentTree folder picker in :app. Create writes a real vault into an app-private working copy and remembers its location. The cloud working-copy round-trip (materialize/flush) that actually opens a vault stored in a Drive / Dropbox / OneDrive folder lands in the next slice — so this slice's working open paths are Create + the demo vault, and opening a remembered cloud location surfaces an honest "arrives in the next update" affordance. Android-only; no core / ffi / on-disk-format change. |
| Android app — cloud-drive working-copy lifecycle (cloud-drive epic, slice 5) | ✅ (2026-06-28): the app now opens a remembered cloud-drive vault and opens a newly-created vault into Browse via the SAF working-copy round-trip — materialize (SAF→working), sync, operate, flush (working→SAF after every commit), governed by push-before-pull (flush pending writes before materializing the cloud copy). Replaces the two Slice-4 seams (the deferred cloud-open and the create-then-return-to-Selection paths). Pure Kotlin — host-tested + compile-verified; instrumented E2E tests land in Slice 6 below. Android-only; no core / ffi / on-disk-format change. |
| Android app — cloud-drive instrumented E2E (cloud-drive epic #321 complete; #327 fixed) | ✅ (2026-06-28): the full Android cloud-drive provisioning epic (#321) is instrumented-proven end-to-end on a real device (RedMagic 11 Pro NX809J, Android 16). Six classes of instrumented tests over real SAF: a test DocumentsProvider (Task 4); SafCloudFolderPort factory branches (Task 5); the working-copy lifecycle — create→flush→materialize→open, offline-flush-retry with PendingFlushNotPersisted, and an offline-create no-clobber guard that proves the #327 fix (Task 6); and two-working-copies full-content convergence over real SAF + the real Rust merge engine (Task 7). The #327 fix spans three layers: createThenOpen escalates PendingFlushNotPersisted when the marker write fails (Task 1); materialize refuses to pull from a manifest-less cloud so an offline create is never clobbered (Task 2); :app routes PendingFlushNotPersisted to the selection screen rather than crashing (Task 3). All 35 :app + 21 :kit instrumented tests pass on the emulator (35/35 + 21/21); real-device createComposeRule Compose UI tests are a known Android 16 device-level compatibility limitation that does not affect the SAF/native-.so smoke tests (all of which pass on the real device). Real-device biometric on a physical phone, the interactive SAF folder-picker UiAutomator path, and Play Store infra remain out of scope. Android-only; no core / ffi / on-disk-format change. |
| Android app — cloud-vault device enrollment + biometric write-reauth and open | ✅ (2026-06-29/2026-06-30): a device can be enrolled against a cloud (SAF) vault (opt-in "Remember this device" at unlock), writes to that cloud vault are gated by the same 30 s GraceWindowReauthGate the demo/local vault uses (write-reauth parity), and a cloud vault can now be opened biometrically (no password required after enrollment — emulator-verified; on-device follow-up deferred). Per-vault keyed: the Keystore enclave + enrollment metadata are namespaced by cloudVaultKey(treeUri) so the demo vault and multiple cloud vaults hold independent secrets with no cross-talk, distinct from the demo's DEFAULT_ALIAS/devicesecret/ namespace (zero migration; demo path byte-identical). Enrollment is atomic incl. the cloud round-trip: mint devices/<uuid>.wrap into the working copy → store the secret in the keyed Keystore enclave → flush the slot to the cloud via the throwing mirror.flush(); if the flush fails the whole enrollment rolls back (a partially-enrolled device is worse than none). New :app CloudDeviceUnlock factory + pure cloudReauthRoute gate-decision (GRACE_WINDOW only when enrolled and the stored vaultId matches the open vault — a stale enrollment never blocks writes) + cloudEnrollThisDevice orchestration + openCloudTarget biometric path (routes DeviceSecret through the same openCloudTarget / open_with_device_secret manifest-verify-before-decrypt as the demo vault — not a weaker open); the pure layer (DeviceUnlockCoordinator, GraceWindowReauthGate) is unchanged. Host-tested (route decision table, atomic enroll-with-rollback, per-key metadata isolation, unlockBiometricEnrolled totality) + :kit instrumented (keyed-enclave isolation, cloud enroll SAF round-trip, grace-window boundary via a counting authorizer, cloud-biometric-button shown/absent) — host gate and emulator green. On-device proven for write-reauth (RedMagic, real Google Drive folder, 2026-06-29): opt-in enrol prompt → silent in-window write → real biometric prompt past the 30 s window. Biometric cloud open is emulator-verified; on-device proof deferred. Follow-ups filed: Google Drive SAF flakiness (#330), custom-ROM picker can't grant a local/non-GDrive SAF tree (#331), UnlockScreen UX polish (#332). Android-only; no core / ffi / on-disk-format change. |
| Android app — SAF eventual-consistency hardening | ✅ (2026-06-29, #330): a RetryingCloudFolderPort decorator wraps the SAF CloudFolderPort with bounded retry-with-backoff on CloudFolderException and, for write, a read-back byte-equality verify — so an eventually-consistent provider (Google Drive caches listings, defers writes) that fails a cloud create/sync on the first attempt is absorbed instead of routing silently back to Unlock. write retries on throw / invisible-read-back / wrong-bytes; list/read retry on exception; delete retries without read-back (idempotent). Pure-JVM, host-testable seams (sleep/onRetry); the SAF factory, VaultMirror, and CloudFolderPort are unchanged; production wiring is a one-line wrap in openCloudTarget. Both flush and materialize benefit. A native provider-SDK path (Dropbox/Drive OAuth, strongly consistent) is tracked as an additive CloudFolderPort epic (#334). Android-only; no core / ffi / on-disk-format change. |
| Android app — UnlockScreen UX polish | ✅ (2026-06-29, #332): the walking-skeleton unlock flow now shows a progress spinner and disables every control while the multi-second Argon2id open runs (a correct unlock no longer looks like a dead button), surfaces a typed error Toast on a failed demo/password unlock (wrong password / wrong recovery / invalid phrase / generic — previously a silent return to Unlock), and titles the screen by target (the demo vault vs the cloud folder's display name). Two pure host-tested helpers (unlockScreenTitle / unlockFailureMessage, the latter total over Throwable with the §13 anti-oracle conflation preserved) + UnlockScreen gains title / isUnlocking params + AppRoot resets the in-flight flag in a finally around all three open entry points (password, cloud, biometric). :app-only; no core / ffi / on-disk-format change. |
| Android app — unsynced-create warning banner (#329) | ✅ (2026-07-01): if an offline-created cloud vault fails to push and its pending-flush marker can't be persisted (PendingFlushNotPersisted), the Unlock screen now shows a persistent warning banner so the user knows their only copy is still local until the next successful sync. Route.Unlock carries an unsyncedCreateWarning flag; the underlying no-clobber guard and push-before-pull retry (#327) are unchanged — this only adds the user-facing warning. :app-only; no core / ffi / on-disk-format change. |
| Android app — cloud write-reauth gate re-target (#340) | ✅ (2026-07-01, security): the write-reauth gate now arms on the first biometric/password open of a remembered cloud vault. Previously the gate was chosen from the pre-open location.vaultUuidHex — empty ("") for a SAF-picked vault until its UUID is learned during open — so it fell to NOOP and that first session's writes were ungated (the open itself was always correctly authorized against the enrollment metadata; only the write-reauth gate was missing). A new pure RetargetableReauthGate decorator (:vault-access) is handed to the open and re-targeted from the resolved UUID inside onVaultUuidLearned (via cloudGateForResolvedVault), so an enrolled cloud vault selects GRACE_WINDOW against its real UUID; un-enrolled/stale enrollment still yields NOOP. Host-tested (wrapper seed/retarget ordering + boundary decision); openBrowseWithSync and the demo path untouched. Android analog of iOS #284. :vault-access + :app; no core / ffi / on-disk-format change. |
| Android app — biometric-unlock failure feedback + remember-device reset (#341, #342) | ✅ (2026-07-02): a non-cancel DeviceUnlockError from a biometric unlock now surfaces a typed Toast on both the demo and cloud paths (a user cancel stays silent) — previously the ephemeral DeviceUnlockViewModel's Failed state was discarded and the failure was invisible (#341); and the "Remember this device" checkbox resets on every Unlock-screen entry so its tick never carries across vaults (#342). A pure exhaustive deviceUnlockFailureDisplay classifier (sibling of mapBiometricError, host-tested over the full taxonomy) drives the Toast; the demo path reads the terminal state before refresh() clobbers it, the cloud path binds the VM to a local. Android parity with the iOS analogs folded into #284. :app-only; no core / ffi / on-disk-format change. |
| iOS app — block-CRUD UI affordance | ✅ (2026-06-20): SwiftUI affordances over the browse stack for create_block ("New block" toolbar button), rename_block (per-block Rename swipe action), and move_record (per-record Move swipe action → target picker sheet) — wired over the existing uniffi session via host-tested VM logic and a real-FFI create→move→read-back round-trip. iOS-only; no core / ffi / on-disk-format change. |
| Desktop app (Tauri) — block-CRUD UI affordance | ✅ (2026-06-20): Svelte affordances over the browse UI for rename_block (per-block rename dialog) and move_record (per-record move dialog with target picker) — wired over the existing Tauri IPC commands, completing the block-CRUD tier on all three platforms (create_block already shipped in D.1.4). Desktop-only; no core / ffi / on-disk-format change. |
| Desktop app (Tauri) — password re-auth before writes | ✅ (2026-06-21): every mutating vault write on desktop (add/edit/delete/restore record, share/revoke, contacts, move record, rename block) now asks for a password re-confirmation first, gated by a configurable grace window (default 2 min — one prompt covers a burst of edits). Opt-in default-on; the Settings dialog exposes a toggle + a grace-window field. A pure frontend writeGuard + ReauthPasswordDialog layer — the existing verify_password Tauri command does the check (no new FFI surface). Desktop-only; no core / ffi / on-disk-format change. OS-biometric path (Touch ID / Linux / Windows Hello) is a follow-up issue. |
| Desktop app (Tauri) — retention + per-block purge UX | ✅ (2026-07-10): the desktop surface for the #399/#402 trash-purge FFI. Run retention now — a two-step dialog previews how many trashed blocks are past the retention window (expired_trash_entries) with the oldest age, then commits auto_purge_expired; Delete forever — a per-row permanent purge (purge_block) behind the shared confirm; Empty trash — a whole-trash batch purge (empty_trash) behind the same re-auth gate, shown only when the trash list is non-empty. Both irreversible writes go through the existing password re-auth gate. The retention window is a new configurable vault setting (days; default 90, bounds 1–3650). Three Tauri commands consuming secretary-ffi-bridge directly (no new FfiVaultError/AppError variant); desktop-only, no core / ffi / on-disk-format change. iOS and Android both shipped Trash browsers next (see the rows below). |
| iOS app — Trash browser | ✅ (2026-07-11): a native Trash screen — list trashed blocks (name + tombstoned-since), restore, delete forever (purge_block), empty trash (empty_trash), and run retention now (preview via expired_trash_entries → commit auto_purge_expired) against the 90-day default — behind the existing Face ID write-reauth gate. Reached by projecting the existing bridge list_trashed_blocks primitive onto uniffi + pyo3 (the same bridge-only→projected pattern as the record-edit / sync surface); no new FfiVaultError variant. Mirrors the desktop retention/purge UX above. The retention-window setting now ships in the iOS Settings screen row below (Android still pending). iOS-only; no core / on-disk-format change. |
| Android app — Trash browser | ✅ (2026-07-11): the Android mirror of the iOS Trash browser above — a Compose TrashScreen listing trashed blocks (name + tombstoned-since), restore, delete forever (purge_block), empty trash (empty_trash), and run retention now (preview via expired_trash_entries → commit auto_purge_expired) against the same 90-day default — behind the existing Android biometric write-reauth gate. Consumes the existing list_trashed_blocks / expired_trash_entries / auto_purge_expired uniffi surface (already projected for iOS); no new FFI, no new VaultBrowseError variant. Trashed dates render locale-aware (device zone + locale), matching desktop (#413, fixed 2026-07-11). Deferred: the retention-window setting (needs a settings FFI + an Android Settings screen — same gap as iOS). Android-only; no core / ffi / on-disk-format change. |
| iOS app — Settings screen | ✅ (2026-07-12): a native SwiftUI Settings screen (gear in the browse toolbar) exposing the two per-vault controls over the new read_settings / write_settings uniffi surface (via an FFI-free SettingsPort): retention window (days, 1–3650, default 90) — now read by the Trash retention path — and re-auth grace (minutes, 0–60, default 2). Save is behind the existing Face ID write-reauth gate; a changed grace window live-retargets the gate (a new RetargetableReauthGate), applied strictly after a successful save so a user outside the current grace window can't widen it to self-authorize the widening. The save re-reads the two UI-less fields (auto-lock / require-password) so a partial write never drops them. The gate is seeded from the persisted grace at open (effective default 30 s → 2 min, matching the schema/desktop). Host-tested VMs + gate + a real-FFI round-trip; no core / on-disk-format / FFI change. The Android Settings mirror shipped 2026-07-12 (row below) — the feature is now complete on both mobile platforms. |
| Android app — Settings screen | ✅ (2026-07-12): the Android mirror of the iOS Settings screen above — a Compose Vault settings screen (its own browse entry, distinct from the device-enrollment "Device settings") over the same read_settings / write_settings uniffi surface via an FFI-free SettingsPort, exposing the two per-vault controls: retention window (days, 1–3650, default 90) — now read by the Trash retention path — and re-auth grace (minutes, 0–60, default 2). Save is behind the existing Android biometric write-reauth gate, re-reads the two UI-less fields (auto-lock / require-password) so a partial write never drops them, and — strictly after a successful save — retargets the live gate to a changed grace window (an additive RetargetableReauthGate.retargetWindow, seed-at-now), so a user outside the current grace window can't widen it to self-authorize the widening. The shared gate is seeded from the persisted grace at open across the local + cloud paths, moving Android's effective default 30 s → 2 min to match the schema/iOS/desktop (user-approved). Host-tested SettingsModel (retarget-after-save ordering pinned) + :kit adapter; no new VaultBrowseError/FFI variant; no core / on-disk-format change; #![forbid(unsafe_code)] intact. |
| Purge-count post-op feedback (Desktop + iOS + Android, #411) | ✅ (2026-07-11): empty-trash / delete-forever / retention now leave behind an inline post-op status banner instead of a silent return — "Purged N items" (singular-safe), a distinct no-op message when nothing was purged, and a warning variant when some trashed files couldn't be removed from disk. A shared PurgeNotice/formatPurgeNotice contract (TypeScript/Swift/Kotlin) carries the outcome from the FFI call through the existing re-auth write wrapper to the UI; pre-op confirmation dialogs are unchanged. Cross-platform UI-only slice; no core / ffi / on-disk-format change. |
| Write-action debounce (Android + iOS, #254) | ✅ (2026-06-18): an in-flight guard so no write action (record Add/Edit commit, list Delete/Restore) executes twice from concurrent or rapid-repeat taps. The host-tested model owns the guard; the UI just disables the button. Android RecordEditModel.commit() gains inFlight (blocks a concurrent coroutine) and committed (blocks a post-success re-tap in the render gap before the form clears); VaultBrowseModel gains a global writing flag covering delete/restore. The keystone host test fires two concurrent commit()s through an injected write gate and asserts exactly one appendRecord; Compose disables Save/Delete/Restore/Add while a write is in flight (instrumented). iOS mirrors it: synchronous commit() guards on committed (its actual Add render-gap double-write fix) plus an isWriting flag; VaultBrowseViewModel gains isWriting; SwiftUI .disabled on the matching buttons (swift test 172/172; the SecretaryApp views compile clean on the simulator via ios/scripts/run-ios-tests.sh → build-app.sh). First cross-platform Android+iOS slice in the C.3 run — touches ios/ but no core / ffi / on-disk-format change. |
| Sync orchestration (file watching, cloud-folder integration, conflict-detection scheduling — headless, exposed via FFI) | ✅ Sub-project C through C.2: pure-Rust sync state machine (C.1 / C.1.1a / C.1.1b — sync_once → prepare_merge → commit_with_decisions over an authenticated VaultBundle of sibling conflict-copies, block-first / manifest-last atomic writes with idempotent crash recovery) plus the headless secretary-sync CLI (C.2 — once and run subcommands, notify-driven daemon loop with trailing-edge debounce, partial-download size-stability gate, host-local lockfile, two-instance convergence test). Mobile sync adapters (C.3) in progress — the iOS orchestration core shipped (see the iOS sync row above); file detection + UI shipped; the Android orchestration core shipped, its real UniffiVaultSyncPort adapter (the :kit module) landed host/build-verified, the emulator round-trip is now proven on Medium_Phone_API_36.1, and folder-change detection shipped (a host-tested debounce core + a real FileObserver watcher, foreground-only) (see the Android row above). C.4 (cross-device convergence conformance) ✅ 2026-06-15 — two device identities reconciling through a shared folder converge to identical logical state (order-independent for automatic merges) (core/tests/convergence.rs); the same four scenarios are also verified in the stdlib-only clean-room (conformance.py), proving order-independence from the spec docs alone. Android emulator round-trip + folder-watch ✅ 2026-06-16; Compose sync render ✅ shipped 2026-06-16 (slice 5); first runnable :app walking skeleton ✅ shipped 2026-06-17 (slice 6 — Compose unlock → silent sync → badge over the real makeVaultSync lifecycle); vault open/browse (metadata-only) ✅ shipped 2026-06-17 (slice 7 — open_vault_with_password → BrowseScreen, lock-on-background, new :browse-ui module); reveal-on-tap ✅ shipped 2026-06-17 (slice 8 — per-field expose_* on tap, 30s auto-hide, retained-block wipe cascade; on-device smoke proves the real .so reveal); soft-delete lifecycle ✅ shipped 2026-06-18 (slice 9 — the first Android vault write: show-deleted toggle + tombstone/resurrect over the existing uniffi write surface, file-backed device-UUID under noBackupFilesDir, writes serialized under the session lock/wiped guard, on-device round-trip smoke); record add + edit ✅ shipped 2026-06-18 (slice 10 — RecordEditForm + RecordEditModel mirroring iOS RecordEditViewModel, text + bytes-as-hex fields, editable tags, kind picker, built on the slice-9 write infra, on-device add/edit round-trip smoke; no core / ffi / on-disk-format change); sync-on-browse ✅ shipped 2026-06-18 — sync badge + sync-at-unlock re-integrated onto the browse screen via a new app-level BrowseWithSyncScreen (reused SyncScreen stacked above BrowseScreen; background sync-at-unlock via launchSyncAtUnlock; monitor lifecycle bound to Browse composition; both platforms run a separate sync-pass Argon2id, mitigated by background execution; Android-specific delta: cannot reuse the session for the vault UUID, provisions it via goldenVaultUuid; mirrors iOS's unified VaultBrowseScreen; Android-only, no core / ffi / on-disk-format change). |
| Platform UIs (desktop: Tauri 2 universal client — Svelte/TypeScript + Rust, macOS / Linux / Windows; mobile: native SwiftUI / Jetpack Compose over uniffi, iOS / Android) | 🚧 Sub-project D — desktop pivoted from NiceGUI to Tauri 2 in 2026-05 (ADR 0007); mobile kept native over uniffi in 2026-06 (ADR 0008). D.1.1 walking skeleton (unlock + block-list scaffold + vault-stored auto-lock settings + auto-lock timeout + lock button), D.1.2 (read-only browse — block detail → record list → field viewer with per-field reveal/mask, auto-hide, and copy-with-auto-clear), D.1.3 (vault create wizard — folder pick + probe, credentials, mnemonic backup, Unlock pre-fill + "create a vault here" affordance), D.1.4 (vault edit — add/edit records: native-BlockPlaintext bridge primitives, unknown-preserving lossless write path, IPC commands, record editor UI) shipped 2026-05-30, D.1.5 (delete/trash — record tombstone/resurrect over the lossless write path, a Rust-gated "show deleted" toggle, whole-block trash/restore, and a Trash view that lists trashed blocks by name) shipped 2026-05-31, D.1.6 (share a block — import a peer's contact card, pick it in a share dialog, and append it as a recipient; a bridge-thick contacts subsystem owns all contacts/ I/O with parse-then-verify_self TOFU import and gatekeeper DTOs that keep card bytes/keys server-side) shipped 2026-05-31, and D.1.7 (contacts management — export-my-card to a chosen folder + a standalone Contacts pane that lists imported contacts with per-contact recipient counts and a warn-but-allow delete that makes "delete ≠ revoke" explicit) shipped 2026-06-03, and D.1.8 (per-block recipients — a read-only "Shared with" banner in the records view listing who a block is shared with: each recipient uuid resolved to a name with the owner shown as "You", and a deleted contact's still-shared block rendered as "Unknown contact (uuid…)" so the residual keyholder stays visible) shipped 2026-06-04, and D.1.9 (per-contact reverse map — expand a contact in the Contacts pane to list the blocks they receive and click one to open it; a read-only contact_blocks bridge primitive inverts the shared_block_count scan, behind a list_contact_blocks IPC command and an inline lazily-fetched ContactRow expansion; no core / FfiVaultError / binding change) shipped 2026-06-04, and D.1.10 (revoke primitive — the frozen-core revoke_block_recipient that #177 was waiting on: re-keys a block under a fresh content key, re-wraps for the remaining recipients only, drops the revoked uuid from the manifest, re-signs Ed25519 ∧ ML-DSA-65 and atomic-writes block-then-manifest; extracts a shared rewrite_block_with_recipients engine from share_block; a fail-fast CannotRevokeOwner guard — the owner is always a recipient and revoking it would brick the block; a bridge revoke_block_from; two new typed errors threaded through every binding + the Swift/Kotlin/pyo3 conformance harnesses; vault-format §6.5.1 + crypto-design §7.3 + a clean-room revoke KAT in conformance.py; revocation is forward-only by design) shipped 2026-06-05, and D.1.11 (desktop revoke UI — an always-visible "Revoke" ✕ on both share surfaces: the per-block "Shared with" banner (every non-owner recipient row) and the per-contact reverse map (each block a contact receives), gated behind a confirm dialog whose copy states the forward-secrecy boundary explicitly (the former recipient keeps anything they already saw); a revoke_block_from IPC command wraps the D.1.10 bridge primitive, the mutation path surfaces typed errors with no read-path leniency, the owner is never offered a control, and a successful revoke refreshes the banner / the contact's shared-block count) shipped 2026-06-05, and D.1.12 (desktop polish batch — a vendored inline-SVG icon system replacing every color-emoji icon with eight currentColor Lucide components that render identically light/dark and on bare-Linux installs with no runtime dependency, aria-controls paired with aria-expanded on both disclosure toggles, an Esc-pops-a-browse-level affordance behind a pure shouldPopOnEscape guard that no-ops at the root / form levels / with a dialog open / in a focused form control, and the duplicated lock_session session-lock helper hoisted into commands::shared and adopted across all eight command modules; no core / ffi / bridge change) shipped 2026-06-05, and D.1.13 (sync bridge primitive — sync_pass_pause_on_conflict auto-applies safe sync arms, pauses on tombstone veto; bridge sync_status + sync_vault; five new FfiVaultError sync variants threaded through all bindings; sync_pass classification KAT; sync functions bridge-only, uniffi/pyo3 projection deferred (#187); no core / on-disk-format / crypto change) shipped 2026-06-06, and D.1.14 (desktop sync UI — TopBar "sync pill" showing "Synced …"/"Never synced" that doubles as the "Sync now" trigger + a centered password re-prompt modal; sync_status/sync_now Tauri commands; desktop DTOs; the five D.1.13 sync AppError codes threaded into the TS layer; no core/ffi/FfiVaultError/UDL/bridge change — a pure desktop slice over D.1.13's primitive) shipped 2026-06-07, and D.1.15 (interactive conflict resolution — turns the D.1.13/D.1.14 ConflictsPending dead-end into an actionable per-record Keep mine / Accept delete modal that lists each disputed record by metadata only (type · tags · field names · timestamps · which device deleted it; no secret values) plus a read-only "auto-merged" notice for field-level LWW collisions, defaulting to Keep mine so nothing is silently lost; a stateless recompute-on-commit two-call flow where sync_pass_inspect returns the veto + collision metadata + a manifest_hash freshness token committing nothing and sync_pass_commit_decisions recomputes the deterministic draft, rejects with EvidenceStale on a mid-modal disk change, then commits; a cross-layer slice spanning core (a metadata-only RecordCollisionSummary on DraftMerge) + secretary-cli + bridge (VetoDto/CollisionDto/VetoDecisionDto, sync_commit_decisions, a SyncDecisionsIncomplete error un-collapsed from SyncFailed) + uniffi/pyo3 (a new FfiVaultError variant threaded through all bindings + the Swift/Kotlin conformance harnesses) + desktop (a ConflictResolutionDialog wired into the SyncPill flow, reusing the sync password); commit happy-path + stale-token rejection covered at the cli layer, manual GUI smoke still deferred (#161)) shipped 2026-06-08. On the mobile track, D.3 slice 1 (iOS XCFramework + linked-call proof) shipped 2026-06-10 — a reproducible build-xcframework.sh, a SecretaryKit Swift Package, and a run-ios-tests.sh that opens golden_vault_001 on a simulator; no app UI yet. B.3 (iOS Secure Enclave device unlock) shipped 2026-06-11 — a pure, FFI-free SecretaryDeviceUnlock Swift package (unlock orchestration + typed errors, host-tested via swift test) and iOS adapters in SecretaryKit/DeviceUnlock/ (real uniffi port, non-exportable SE P-256 conformer behind biometric SecAccessControl, Keychain metadata store); the SE conformer is compile-verified on the simulator with a fake enclave; real biometric release on a device is the #202 follow-up. See ios/. The L4 end-to-end test is manual-only (not in CI) and deferred (#161). secretary-ffi-uniffi is the mobile UI path (native SwiftUI / Compose); secretary-ffi-py is the automation / scripting path. |
A clean-room implementation in any language can be built from docs/ alone. This is verified by core/tests/python/conformance.py — a uv run-compatible clean-room Python script (generic crypto primitives via PEP 723; no dependency on secretary-core) that performs (1) full hybrid-decap + AEAD-decrypt + hybrid-verify against the golden_vault_001/ reference vault using only the spec, (2) a cross-language replay of eleven conflict_kat.json merge vectors covering each ClockRelation branch, the tombstoned_at_ms death-clock semantics, the §11.3 identity-metadata override, and record-level unknown-map collisions; (3) a case-insensitivity self-test guarding hex-comparison drift in py_merge_unknown_map; and (4) a --diff-replay mode used by the fuzz harness for cross-language decoder agreement. All halves run from spec docs alone, with no dependencies on the Rust source.
The project is intentionally being built slowly and carefully. Cryptographic systems that handle multi-decade-lifetime secrets are not the right place to optimize for time-to-MVP. See ROADMAP.md for the phased plan.
This is a personal project by Horst Herb. Issues and pull requests on this repository are welcome once the foundation is in place — see the project status above.
