commit a877b5ccc713e474424ff94a66193e7dae6b11b5
parent cba63fa6f57648f54300b3f2d5d4b854b851b01c
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 20:12:11 +0000
docs: complete Nostr account runtime documentation
- describe implemented ownership state persistence and relay flows
- record the UniFFI lifecycle native artifact and platform boundaries
- document credential exposure recovery and deletion semantics
- align local-relay and verification runbooks with executable tests
Diffstat:
8 files changed, 121 insertions(+), 25 deletions(-)
diff --git a/docs/adr/0001-rust-core-and-uniffi.md b/docs/adr/0001-rust-core-and-uniffi.md
@@ -2,7 +2,7 @@
## Status
-Accepted.
+Accepted and implemented.
## Decision
diff --git a/docs/adr/0003-account-metadata-and-secret-storage.md b/docs/adr/0003-account-metadata-and-secret-storage.md
@@ -2,7 +2,7 @@
## Status
-Accepted.
+Accepted and implemented.
## Decision
diff --git a/docs/architecture/nostr-accounts.md b/docs/architecture/nostr-accounts.md
@@ -8,14 +8,17 @@ public availability state to `Available`, and retains the original metadata.
## Status
-Initial architecture contract. Update this document as each implemented
-checkpoint makes paths and behavior concrete.
+Implemented MVP architecture.
## Ownership
Rust `AppCore` owns the saved-account registry, selected account, active
signer/session, persistence, recovery, relay/profile work, immutable snapshots,
and safe errors. Kotlin is a thin lifecycle and presentation shell over UniFFI.
+`PersistentAppCore` composes the application ports with one SQLite `Database`;
+the FFI `StudioAppCore` adds the OS credential adapter, clock, SDK relay client,
+and observer ownership. `RadrootsApplication` creates one native core and one
+`StudioAppStore` for the application composition and closes both on disposal.
## Identity and state
@@ -33,14 +36,40 @@ working session. Sign out retains the saved account and credential. Confirmed
removal deletes account-private state and credential, then chooses a
deterministic selected fallback without activating it.
+Generate and import durably create and select a signed-out account. Import
+accepts nsec or canonical secret-key hex and rejects duplicates without
+overwriting credentials. A saved account explicitly marked
+`CredentialMissing` is the only repair exception. Removal requires a
+revision-bound, single-use confirmation token so stale UI confirmation cannot
+delete a changed target.
+
+Public state revisions increase only when public state changes. Observers get
+the current snapshot at subscription and later revisions after locks are
+released. A relay completion is bound to its initiating active public key; a
+sign-out or replacement makes the completion stale and prevents publication.
+
## Data partitioning
-Public Nostr events may be cached by event ID and subject pubkey. Local account
-metadata and typed private preferences are keyed by owner pubkey. Secret keys
-are never stored in SQLite.
+SQLite stores public account metadata, selected public key, verified kind-zero
+profile cache, account-owned typed preferences, migration state, and the
+non-secret recovery journal. Public Nostr events are keyed by event ID and
+author. Account-private values are keyed by owner public key and cascade on
+removal. Secret keys are never stored in SQLite.
+
+The production database is exactly
+`ProjectDirs::from("org", "radroots", "studio").data_dir()/studio.sqlite3`.
+The `directories` crate maps that base to the platform application-data area;
+tests inject temporary or in-memory databases instead.
## Relay and profile flow
Relays are global WebSocket endpoints from `RADROOTS_NOSTR_RELAYS`. Activation
-emits cached profile data before a verified kind-0 refresh. Offline or invalid
-relay data retains the cache and produces a nonfatal state.
+loads cached profile state. Refresh then queries kind-zero events using the
+production SDK adapter, verifies event ID, signature, author, kind, bounds, and
+metadata, and selects newest timestamp with lowest event ID as the tie-breaker.
+Offline, timeout, or invalid data retains cache and produces a nonfatal state.
+
+Remote relays require `wss://`. Plain `ws://` is restricted to localhost,
+`127.0.0.0/8`, and `::1`. Development mode falls back to
+`ws://localhost:8080`; packaged mode fails safely if no valid relay is
+configured. Tests use only ephemeral loopback relays or controlled fakes.
diff --git a/docs/architecture/rust-ffi-boundary.md b/docs/architecture/rust-ffi-boundary.md
@@ -16,16 +16,26 @@ Normal public DTOs contain no secret. The only exception is the direct,
one-time generated-key receipt. Generated bindings belong under
`build/generated` and are not committed.
-Potentially blocking storage, credential, and relay operations must not run on
-the Compose thread. Kotlin closes its observer and AppCore during application
-disposal. Tests cover native loading, callback ordering, re-entry,
-deregistration, cancellation, stale completion, and close races.
+Potentially blocking storage and credential operations use Rust blocking tasks;
+relay operations use the supervised Tokio runtime. UniFFI exports them as
+suspending Kotlin calls. `StudioAppStore` accepts at most one command at a
+time, keeps Compose state read-only to consumers, rejects stale observer
+revisions, and closes its removal ticket, subscription, gateway, and native
+core during application disposal.
The public snapshot DTO carries revisioned lifecycle, account, session, relay,
profile, and safe-error values. It contains no credential field. A generated
account's nsec is confined to the explicit one-time receipt added with the
command boundary.
+The exported command surface is bootstrap, snapshot, generate, import, select,
+activate, sign out, profile refresh, removal request/confirmation, subscribe,
+unsubscribe, and shutdown. `ObserverSubscription.unsubscribe` is idempotent;
+`StudioAppCore.shutdown` deregisters remaining observers and signs out before
+the generated object handle is closed. Callback tests re-enter `snapshot`,
+observe asynchronous profile refresh, and prove no callback arrives after
+unsubscribe.
+
## Native artifact
The FFI crate builds as both an `rlib` for Rust tests and a `cdylib` for the JVM
@@ -56,3 +66,8 @@ current-host library under JNA's platform resource prefix inside the desktop
resources, allowing the packaged JVM runtime to extract and load it without a
machine-specific absolute path. `NativeLoaderTest` crosses the generated ABI
and verifies the native crate version without opening storage or credentials.
+
+Current platform resource prefixes are `darwin-aarch64`, `darwin-x86-64`,
+`linux-aarch64`, `linux-x86-64`, `win32-aarch64`, and `win32-x86-64`. The build
+stages only the current host artifact; each release platform must build and
+smoke its own package rather than reusing another platform's dynamic library.
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -550,7 +550,7 @@ handoff commit sequence.
### RCLD-12
- [x] 60. Add dependency and license documentation.
-- [ ] 61. Complete architecture, security, testing, and runbook documentation.
+- [x] 61. Complete architecture, security, testing, and runbook documentation.
- [ ] 62. Add Makefile-governed final validation tasks and platform ledger. Do
not add `.github/**` or `scripts/**`.
- [ ] 63. Perform final source audit and acceptance reconciliation.
diff --git a/docs/runbooks/local-relay-development.md b/docs/runbooks/local-relay-development.md
@@ -14,6 +14,8 @@ without any public-network dependency:
```sh
cargo test --manifest-path core/Cargo.toml -p radroots-studio-application sdk_client
+cargo test --manifest-path core/Cargo.toml -p radroots-studio-storage local_relay_e2e
+cargo test --manifest-path core/Cargo.toml -p radroots-studio-ffi ffi_callback_receives_async_profile_refresh
```
Development and tests use local WebSocket relays only. The default development
@@ -23,3 +25,16 @@ the ordered relay list with comma-separated `RADROOTS_NOSTR_RELAYS` values.
Plain `ws://` is accepted only for `localhost`, `127.0.0.0/8`, and `::1`.
Production remote endpoints require `wss://`. Tests must fail closed rather
than contact public relays when a local fixture is unavailable.
+
+For interactive development, either start a relay at
+`ws://localhost:8080` or set `RADROOTS_NOSTR_RELAYS` before `make dev` or
+`make run`. Use a comma-separated ordered list when testing more than one
+relay. Do not put credentials in this variable. The development launcher sets
+the native core to development mode; packaged applications use packaged mode
+and therefore require explicit valid relay configuration.
+
+If refresh fails, inspect the safe relay/profile state in the active home
+screen. Cached metadata remains visible. Invalid configuration is corrected by
+restarting with a valid environment value; there is no in-app relay editor in
+this MVP. Local test failures should be reproduced with the focused commands
+above before broad workspace validation.
diff --git a/docs/security/key-management.md b/docs/security/key-management.md
@@ -2,8 +2,7 @@
## Status
-Initial security contract. Implementation-specific recovery phases and
-platform results will be added with their checkpoints.
+Implemented MVP security contract.
## Storage boundary
@@ -25,8 +24,12 @@ non-serializable values.
Generated nsec is returned once in a direct operation receipt, displayed in
non-saveable Kotlin state, and cleared after acknowledgement or disposal.
-Explicit clipboard copy uses conditional delayed clearing. Imported key input
-crosses an unavoidable JVM String boundary once and is cleared immediately.
+Explicit copy places the nsec in the operating-system clipboard, which is an
+additional user-authorized exposure that the application cannot reliably
+revoke. Imported key input crosses an unavoidable JVM `String` boundary once;
+the masked Compose draft is cleared immediately when the command is accepted,
+before the native coroutine executes. The in-flight JVM argument cannot be
+guaranteed zeroized and is never logged or added to public state.
Keyring unavailability is a safe recoverable error. Cross-resource operations
publish no partial success and use a non-secret journal for restart recovery.
@@ -46,3 +49,17 @@ An empty journal performs no keyring operation. Removal recovery retries an
intent, continues honestly from `CredentialDeleted`, completes metadata and
account-namespace cleanup, persists deterministic fallback selection, and then
finalizes the entry. Keyring failure leaves the phase unchanged for retry.
+
+Add/import records intent before credential creation. After credential write it
+records `CredentialWritten`, writes public metadata and selection, records
+`MetadataWritten`, then finalizes. A metadata failure attempts credential
+compensation; a failed compensation leaves enough non-secret journal state for
+startup recovery. Removal records intent, signs out if necessary, deletes the
+credential, records `CredentialDeleted`, deletes public and account-owned data,
+records `MetadataDeleted`, persists fallback selection, and finalizes.
+
+Corrupt or inaccessible SQLite fails safely and is not silently recreated.
+Locked or unavailable credentials do not become watch-only accounts. Platform
+credential behavior must be checked on macOS, Windows, and Linux; the real
+keyring smoke remains ignored by default because it mutates user credential
+state.
diff --git a/docs/testing/nostr-accounts-test-plan.md b/docs/testing/nostr-accounts-test-plan.md
@@ -2,8 +2,9 @@
## Status
-Initial test contract. Record exact commands and completed coverage as the
-runtime is implemented.
+Implemented test contract. There is no numeric coverage threshold; the suite
+uses behavior-focused best-effort coverage and fails if no Kotlin tests are
+discovered.
## Required lanes
@@ -35,13 +36,32 @@ cargo clippy --manifest-path core/Cargo.toml --workspace --all-targets -- -D war
cargo test --manifest-path core/Cargo.toml --workspace
```
-Run the existing desktop regression lane separately until the Makefile combines
-the Rust and desktop lifecycles:
+Run the desktop, generated binding, native loader, store, and Compose lanes:
```sh
./gradlew --no-daemon :app:desktop:test
```
-The capsule intentionally has no `.github/**` workflow and no validation
-script. The Makefile will become the combined human-facing command surface
-without changing these repository-owned Cargo and Gradle gates.
+Focused integration paths are:
+
+```sh
+cargo test --manifest-path core/Cargo.toml -p radroots-studio-storage local_relay_e2e
+cargo test --manifest-path core/Cargo.toml -p radroots-studio-storage restart_restores_selection
+cargo test --manifest-path core/Cargo.toml -p radroots-studio-ffi ffi_callback_receives_async_profile_refresh
+./gradlew --no-daemon :app:desktop:test --tests org.radroots.studio.ffi.NativeLoaderTest
+```
+
+`local_relay_e2e.rs` imports and activates a deterministic signer, reads signed
+kind-zero metadata through an ephemeral loopback relay, observes loading/fresh
+revisions, verifies SQLite cache, and checks public redaction.
+`restart_isolation.rs` reopens the database, restores selected accounts, proves
+owner-scoped values remain isolated, and scans bytes for known secrets. FFI
+tests cover callback re-entry, asynchronous refresh, unsubscribe, and shutdown.
+Compose tests cover the complete minimal UI surface and use fake actions rather
+than credentials or public relays.
+
+The real OS keyring smoke test is ignored by default and must be explicitly run
+on each supported target in an isolated test account. Packaging and native
+loader smoke are current-host checks; cross-platform results belong in the
+final validation ledger. The capsule intentionally has no `.github/**` workflow
+and no `scripts/**` command surface.