feat: the Linux target — a jvm() target on shared, and loopky, the headless client - #208
Conversation
The headless client (#54) needs `shared` to build for a desktop JVM. Most of what it needs was already written — it was just filed under `androidMain` by accident of Android having been first. `applyDefaultHierarchyTemplate` grows a `jvmShared` group so Android and the new `jvm()` target share one source set, and the code that is plain JVM moves into it: the `java.time` and `java.security` actuals, the `java.util.zip` helpers, `HttpURLConnection`, and the UniFFI-generated JNA bindings. That last one is the reason to share rather than copy — `uniffi/pubkycore/pubkycore.kt` is generated in the fork and checked in here, and a second copy under `jvmMain` would have to stay byte-identical forever with nothing reporting it when it stopped. `AndroidPubkyClient` and `AndroidHttpFetcher` are renamed to `UniffiPubkyClient` and `JvmHttpFetcher` to match what they now are. The group is added *through* the template rather than with bare `dependsOn` edges: those switch the default template off, and the only sign is a warning saying `iosMain` no longer belongs to a compilation. `kvault` moves out of `commonMain` into `androidMain`/`iosMain`. It publishes android + iOS artifacts and nothing else, so a `commonMain` declaration fails to resolve for `jvm()` for a reason that has nothing to do with the code; the one `commonMain` reference to it was a doc comment. The `.apkg` reader splits the same way. Everything but opening the collection is now `JvmApkgReader`, behind an `AnkiDbOpener` — Android keeps platform SQLite, the desktop target takes `org.xerial:sqlite-jdbc`. Bulk Anki import is the most CLI-shaped job there is (#46), so this is on the critical path rather than a nice-to-have. `Log`'s JVM actual writes everything to **stderr**, debug included: the desktop consumer of `:shared` is a CLI whose stdout is a machine-readable channel, and one stray log line makes `--json` undecodable. Verified: 1,271 shared tests pass on `:shared:jvmTest`, `:composeApp:assembleDebug` and `:shared:compileKotlinIosArm64` are unaffected, `detektAll` is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
The native library lands under JNA's own resource layout
(`linux-x86-64/`, `darwin-aarch64/`), which is what lets it travel *inside the
jar*: `Native.load("pubkycore")` extracts the matching file from the classpath at
runtime, so nobody installing the CLI fetches a native library by hand or sets
`-Djna.library.path`. Built by `build_desktop.sh` in pubky-core-ffi-fork; the
same arrangement `androidMain/jniLibs` already has for the four Android ABIs.
`UniffiPubkyClientJvmTest` is the part that has teeth. The 1,271 shared tests run
against `FakePubkyClient` and would pass identically on a machine where this
directory is empty, the architecture is wrong, or the file is one level off —
none of which shows up until the first homeserver call, hours later, as an
ordinary-looking transport error. Three offline assertions (mnemonic generation,
key derivation, validation) prove the binding is real without asserting anything
about a network.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
The last piece `:shared` needs before a CLI can start Koin: the ~15 bindings the platform module has to answer for, in their desktop form. The seven stores go to one 0600 JSON file each under `$XDG_CONFIG_HOME/loopky`, split preferences from secrets the way Android splits SharedPreferences from its keystore vault. The file is the **default**, not a fallback, and that is the decision rather than an omission: libsecret is usually present on a desktop Linux and usually absent on the headless box an agent runs on, so making a keyring the default would fail exactly where the tool is meant to work. What it costs is stated plainly — a session secret here is protected by file permissions and nothing else — and what is stored is a capability-scoped, expiring session, never a secret key. macOS gets the same file store today; the Keychain is the right answer on that row and is that row's remaining work. Writes are atomic (temp file, then `ATOMIC_MOVE`) because one of the things kept here is the journal of reviews that have not reached the homeserver. `MediaProcessor` is `javax.imageio` and **degrades rather than throws**: a headless JRE with no AWT is a supported deployment, so a failed decode hands the original bytes back and the caller uploads a larger picture than intended. That is the right trade for a client whose card images are overwhelmingly remote refs (#167) that never cross the wire at all. The four remaining collaborators answer with an honest "no" rather than a stub that pretends — a `Speaker` that silently succeeded would let a command claim it read a card aloud. `NoPubkyRingPresence` returning false is load-bearing: it is what makes desktop sign-in a printed QR code, the same fallback a tablet with no Ring gets, instead of a deeplink into nothing. `InlineBackgroundTasks` drops both deferred jobs, because a process that ends when its command does has no "later" — neither job is needed for correctness, and what the CLI owes the user instead is an explicit way to run them. `pubkyEnvironment` is a required parameter with no default. The apps infer it from the build type and cannot be talked out of it (#42); a binary has neither a build variant nor a Settings screen, so the caller has to say. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
`loopky` — deck and card management from a terminal, so an agent can publish the flashcards it is already good at writing (#54). A plain JVM module on `:shared`'s new `jvm()` target; it consumes the repositories directly and never touches `presentation/`, which is what the no-use-case-layer rule buys here. Three properties everything is arranged around. **The session scope is `/pub/loopky/:rw` and nothing else** — not the apps' `DEFAULT_CAPABILITIES`. An agent session therefore cannot write a post, a follow or a profile edit under any bug or any prompt injection, because it was never handed the capability. The announce confirmation (#39) is gone by construction rather than behind a flag: there is nothing to confirm. It costs less than it looks, because deck tagging routes on the subject and a deck manifest's tag record lives in `/pub/loopky/tags/` (Architecture.md §7.7), so `--tag` and the language tags still work. What is given up is a feed presence, which a headless deck tool has no business having — and a display name, which is why `whoami` reports none. **Nothing prompts, and stdout is a machine channel.** `--json` is a versioned envelope carrying the result *and* the failure, with the environment and the indexer on every one — because Nexus answers a query aimed at the wrong network successfully and empty, and an agent that reads `[]` concludes its write failed and retries. Exit codes distinguish not-signed-in from session-expired from network, and session expiry has its own because it is hourly, unrecoverable without a human (#165), and otherwise indistinguishable from a wobble. **Long jobs are resumable and writes are idempotent.** `card add` twice with the same sides is detectable and skipped; `import --resume` checkpoints against the deck on the homeserver rather than a local cursor, because the deck records exactly which cards arrived and survives the sandbox being thrown away. Batch `--from-file` for add *and* edit, since editing is what an agent does after an import and one card write is one `/session` round trip (#105) either way. The import format carries image URLs from day one — `front <TAB> back <TAB> front_image_url <TAB> back_image_url` — because a remote ref costs no bytes and no quota since #167, and it is the one thing neither `.apkg` nor TSV-through-the-parser can express. Login prints a terminal QR (no Ring return-callbacks: there is no app to return to, and a dangling `x-success` bounces a desktop login into the phone app). `LOOPKY_SESSION` is read before the stored session and is the only way in on a sandbox recreated per task. `LOOPKY_ENV` picks the network — a binary has no build type to infer it from — and disagreeing with the session is a distinct exit code rather than a warning. Verified on macOS arm64 against the live network: real `pubkyauth://` URL with `caps=/pub/loopky/:rw` and no callbacks, QR rendered and written as PNG, Nexus trending read on both staging and production, and the exit-code/JSON contract across not-signed-in, unknown-command and bad-input. 23 unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
Two bugs a green build said nothing about, both found by running the binary. `loopky import cards.tsv` dispatched under the verb `"import cards.tsv"`. The verb was "the first two positional words", which is right for `deck create` and wrong the moment a command takes an operand: it matched nothing in the dispatcher and came back as an unknown command, and it named the command `import cards.tsv` in the `--json` envelope. It is now two words for the grouped commands and one for the rest. `loopky --version` printed the usage block, because a command line with no positional words hits the "you gave me nothing" branch first. Also: the JVM `Log` prints a stack trace only under `--verbose`. A warning or an error is worth keeping on a terminal; twelve frames of coroutine machinery under it bury the one line that says what went wrong, and the CLI already turns the same failure into an exit code and a `--json` error object. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
Architecture.md gains §13 for `:cli` — why a JVM target rather than a Rust or Python rewrite (the deck *protocol*, not just its schema, is what a second implementation would have to track: the #49 marker manifest, the concurrent chunk writes, the trailing-chunk sweep, the parser, `.apkg`, SRS); why the session scope is `/pub/loopky/:rw` and what that structurally rules out; why `--json` carries the environment and the indexer on every result; and the exit-code table with the reasoning for session expiry having one of its own. It also records the gaps as decisions rather than omissions: packaging is still a jar plus a JRE, which is exactly what a one-line sandbox install cannot be; the tarball carries both native rows; Windows is out of scope; and whether Loopky can run behind an allowlist proxy at all is unresolved and is a hard blocker for cloud sandboxes, because the homeserver is not a fixed hostname and pkarr's DHT path is UDP. A `cli-linux` CI job is the CLI's equivalent of a journey — those drive a screen with `android-cli` and this has none. It runs the shared suite on the `jvm()` target on a headless Linux x86_64 runner, which is where the `.so` is exercised on real glibc for the first time, and asserts the exit-code and `--json` contract against the built binary. Offline on purpose, so a network blip cannot make it flake. `journeys/RESULTS.md` gains a CLI section with what was checked by hand and, more usefully, what was not: no Ring approval was completed, so every command above the sign-in line is untested end to end and the acceptance criteria that turn on a real session are unmet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
….app The self-tag's subject is a *profile*, so its record goes to `/pub/pubky.app/tags/` (§7.7) — a namespace the headless client's session (`/pub/loopky/:rw`, #54) was never granted. It is best-effort, so nothing broke: it just fired a doomed write on every command that loaded a session, and logged a warning about the scope working exactly as designed. Asked rather than attempted. `Session.canWritePubkyApp` reads the capabilities the homeserver actually granted, so shared code can tell "this write is not mine to make" from "this write failed". A grant on `/pub/` counts, because comparing for equality would read a broader grant as a narrower one; `:r` on the same prefix does not, because read is not write. It is a hint and the doc says so — a plain string prefix, so a capability ending mid-segment would answer true. That costs one optional write attempted instead of skipped, which is what happened before this existed, and the homeserver is what enforces the scope. Not to be promoted to a gate on anything that matters. Also records the Linux CI result in RESULTS.md: `cli-linux` passed, so the cross-built `linux-x86-64/libpubkycore.so` loads through JNA on a real glibc host and the whole shared suite runs there. That was the branch's biggest unknown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
`publish` `require`s both sides of every card, which throws an `IllegalArgumentException` no classifier recognises — so a blank column in the user's own TSV reached them as exit 1 "internal" plus a Kotlin assertion message. It is now exit 9 with a message naming the row, which matters because the whole point of a batch is that nobody is reading it line by line. Checked only where a row becomes a *new* card. An edit row is allowed to carry one side and mean "leave the other alone", and the import path needs none of it because the shared parser already drops half-empty rows. An image counts as a side, which is what the image columns are for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
The load-bearing safety check on the branch had no test. It is what stops the one failure mode this design cannot absorb: a session on one network with `--env` naming the other publishes fine — pkarr resolves both — while every indexer-backed read comes back **successfully empty**, so an agent that writes a tag, reads it back and sees `[]` concludes the write failed and retries. Four cases, including the one that is easy to get backwards: it **fails open**. The check is "this session sits on the *other* environment's known default homeserver", which is a fact; an equality check against the requested environment's default would refuse a legitimate self-hosted homeserver. So it catches the common mistake rather than every mistake, and the doc now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr
Review + a live run of
|
Addendum: a focused pass on session and credential handlingSeparate from the functional review above, at the author's request. Verified on the same live staging run, on disk, not only from the diff. What is right, and verified
S1 — MEDIUM.
|
`"jdbc:sqlite:file:${'$'}{file.absolutePath}?mode=ro"` — `${'$'}` is a Kotlin
template producing a literal `$`, so the constant reached the driver with
`${file.absolutePath}` in it verbatim and the path never arrived. That escape
belongs in KDoc; it leaked in from the heredoc this file was first written
through.
Every desktop `.apkg` read failed at "unable to open database file", which
`readArchive` then reported as "That .apkg has no readable collection" — a wrong
diagnosis pointing at the user's own file. Nothing caught it because the shared
suite drives `JvmApkgReader`'s callers rather than the opener, and the CLI has no
`.apkg` entry point yet, so a declared `actual` for the jvm target has been
non-functional since it landed.
`ApkgReaderJvmTest` is the test with teeth: it builds a real zip around a real
SQLite collection and reads it back, so the opener is actually exercised.
Confirmed to fail on the old string and pass on the new one.
Found in review by jvsena42 on #208.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
The FFI's session JSON carries no `homeserver` field, so `parseSessionPayload` defaults it to `""`. Two of the three paths that mint a session already backfill it — `signInWithKey` from the pre-flight lookup, `registerHeldKey` from the server it registered against — and the **Ring deeplink path did not**. Nor did `adoptSession`, the `LOOPKY_SESSION` path added on this branch. That is worse than the "Unknown" it rendered in Settings. The CLI refuses to run when a session and the requested `--env` disagree, and decides that by comparing the session's homeserver against the other environment's known default — which a blank value never matches. So `ExitCode.EnvironmentMismatch`, its tests and the README's "a session and an --env that disagree is a hard error" all applied to a session shape that `loopky login` never produces. The guard failed open in the common case rather than the documented edge case, and a live staging sign-in reported `"homeserver":""`. `EnvironmentMismatchTest` was green because it hands the function a homeserver by hand — the test proving the test. The new coverage goes through `beginSignIn().complete()` and `adoptSession()` with raw payloads instead, so it exercises the shape the real flow produces. Both fail on the old code. Best-effort: a DHT that will not answer leaves the blank rather than failing a sign-in that has otherwise succeeded. Found in review by jvsena42 on #208, reproduced live against staging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
`WhoamiResult` and `LoginResult` carried no `@SerialName`, so they serialised as `sessionSource`, `configHome`, `sessionLive`, `displayName`, `sessionSecret`, `storedAt` — camelCase, alone on a surface where every other result type is snake_case and `Views.kt` states that as the rule. Both `cli/README.md` and Architecture.md tell an agent to read **`session_live`**, which did not exist. That is the one field the README singles out as "worth checking before starting an hour-long import rather than forty cards in", so a caller following the documented contract read null from it. This is `schema: 1` and there is no consumer yet, which is exactly when to fix it. Also corrects `storedAt`'s doc, which claimed it is "null when `--export` printed it instead of storing it". `login` always persists and always sets it — `--export` *also* prints the secret. As written it told a reader that `--export` is a print-only mode, which is a meaningful claim about where a credential ends up. Found in review by jvsena42 on #208. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
…a side Two findings in the same function, both about `card` writes telling the truth. **The response echoed intent, not result.** `upsertCard` discards the caller's `ord` and recomputes one from the chunk the card lands in, so `card add --json` reported `ord: 1000` on an empty deck where 0 was stored. On a populated deck the two happen to agree, which is why it took a fresh deck to see. For a channel whose stated purpose is that a caller diffs intent against result from these bytes, echoing intent is the one thing it set out not to do. The response now reads the card back — a cache hit, since the write just populated it. **Clearing a side exited 1 "internal".** `--back=` is the CLI's own documented gesture for it, and `DeckRepositoryImpl.upsertCard` `require`s both sides, throwing an `IllegalArgumentException` that `toErrorReason` classifies `Unknown`. So the supported way to clear a side answered with a Kotlin assertion string and the code that means "report this as a bug". Same failure `1870ec4` fixed for `card add`, which the row-level guard cannot cover here: an edit row is *allowed* to be partial, so what has to be checked is the card the edit produces. Now exit 9, naming the card and which side went empty — an agent told "internal" retries, told "bad input" it fixes its input. Found in review by jvsena42 on #208, both reproduced live against staging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
Four inputs the CLI accepted and turned into something the caller did not ask for. **A third TSV column was stored as an image URL without being one.** The image-column format engaged whenever every line had three or more tab fields, so a three-column Anki export — Front / Back / Example sentence, a very common shape — published every card with `MediaRef.Image(url = "una manzana roja")`. Both apps then try to load prose as a picture, the column's real content is lost, and `--json` reports success. Every app-side constructor of a remote image ref takes its URL from a picker; this was the first path where an arbitrary string reached one. Now the format test requires the image columns to hold `http(s)` URLs, and the two entry points differ deliberately: `import` falls through to the text parser (there the third column is content somebody wants imported), while `card add --from-file` errors (there the four-column TSV is what was explicitly asked for). **A malformed `LOOPKY_SESSION` reported `session_expired`.** A parse failure is not an expiry, and exit 4 has a code of its own precisely so an agent can tell a dead session from a wobbly network — teaching it "expired" for a typo'd environment variable is that confusion one layer up. The shape is checked before the FFI sees it. **`--limit twenty` silently became 20**, and `--limit 0` passed straight through, for the one command whose entire output is a ranked list. A silently different N is the class of quiet wrongness the envelope's `indexer` field exists to prevent elsewhere. **A card whose front is `#1 ranked` vanished.** `startsWith("#")` swallowed markdown headings and hashtags, against this file's own contract that a batch producing fewer cards than it has lines is exactly the loss `--json` exists to show. A comment is now `#` followed by whitespace. Also exempts the two test trees this branch added from `MagicNumber` and `TooManyFunctions`, which every other test tree already was — a test class's function count is how much behaviour it pins down. Found in review by jvsena42 on #208. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
A host outside the shipped matrix — an x86-64 Mac, Windows — fails at `Native.load` with `UnsatisfiedLinkError` or `ExceptionInInitializerError`. Both are `Error`s, so they walked past `catch (Exception)`, skipped `fail()`, and produced a bare exit 1 with **nothing on stdout**. That breaks "results and failures both go there as `--json`" for precisely the case `cli/README.md` and `Platform.jvm.kt` both name as expected — the one where an agent most needs a machine-readable reason rather than silence. `startCli` sat outside the `try` for the same reason it should not have: resolving `PubkyClient` is where that load happens. `stopCli` in the `finally` is now best-effort, since Koin may never have started and throwing there would bury the failure being reported. Found in review by jvsena42 on #208. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
Eleven findings, four reproduced on a real staging homeserver with a Ring session. Worth keeping the table rather than only the commits, because two of them had *passing tests* over them: the environment guard's test handed it a homeserver by hand, and the shared suite drives the `.apkg` reader's callers rather than its opener. Both now have tests that go through the real shape, and both were confirmed to fail on the old code first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
|
All eleven fixed, and thank you — the live run caught things a green build could not have. Two of them had passing tests over them, which is the part I'll remember. Follow-up for the version check / The two blockers1 — the blank homeserver. Fixed in Your point about the test proving the test is the reason I did it the way you suggested: the new coverage goes through 2 — the JDBC URL. Fixed in The schema three
The rest
Verified
Still not verified by me: the sign-in half. Your run is the only end-to-end evidence on this branch — I have never completed a Ring approval, so 🤖 Generated with Claude Code |
Round 2 — re-reviewed and re-tested at
|
| Finding | Evidence from this run |
|---|---|
| snake_case keys | whoami --json now returns session_source, config_home, session_live, display_name |
malformed LOOPKY_SESSION |
exit 9 bad_input — "it should look like <pubkey>:<cookie>", and the FFI never sees it |
--limit twenty |
exit 2 usage — "must be a positive whole number, not 'twenty'"; --limit 3 still returns 3 |
card add ord echo |
fresh deck: echoed ord: 0, stored ord: 0 — was 1000 vs 0 |
card edit --back= |
exit 9 bad_input — "Card l6ffjhrowzq2 would be left with an empty back… An image counts as a side" |
3-column prose via card add --from-file |
exit 9, naming the coordinate: "Line 1, column 3 is an image column but holds "una manzana roja"" |
3-column URL via card add --from-file |
still accepted, mime inferred as image/jpeg — the guard did not overshoot |
#1 ranked |
survives as a card front; # a real comment still skipped. 2 written from 3 lines, as intended |
| homeserver backfill (injected path) | adoptSession now resolves ufibwbmed6…, and --env production against it is refused with exit 8 |
That last row is the one that matters most — the environment guard fires for the first time. Which leads to the one real finding of this round.
R2-1 — MEDIUM. The homeserver fix does not heal a session that is already stored, and the guard stays open for it
withResolvedHomeserver() is applied in complete() and adoptSession(). loadPersistedSession (IdentityRepositoryImpl.kt:83) is untouched — it returns sessionStore.load() verbatim.
So the same session behaves two ways depending on which door it comes through. Both of these are the identical staging session on this machine, one run apart:
# read from ~/.config/loopky/secrets.json
$ loopky whoami --env production --json
{"ok":true,…,"homeserver":"","environment":"production","indexer":"https://nexus.pubky.app"}
rc=0 ← the guard does not fire, reads go to the production indexer
# the same secret, injected
$ LOOPKY_SESSION=… loopky whoami --env production --json
{"ok":false,"error":{"code":"environment_mismatch","exit":8,
"message":"This session is on staging (homeserver ufibwbmed6…) but --env/LOOPKY_ENV says production…"}}
rc=8 ← correct
Anyone who ran loopky login before this commit — and every Android/iOS user who signed in through Ring, since the blank was written by the shared code — keeps a blank homeserver until they sign in again. Nothing prompts them to, and session_live: true says the session is fine. The commit message's own framing is that the guard "failed open in the common case"; for existing installs it still does, and there is now no symptom at all because whoami looks healthy.
Suggestion: re-resolve in loadPersistedSession when homeserver.isBlank(), and persist the result so it costs one DHT lookup once rather than every invocation. The helper is already suspend and already best-effort, so it drops straight in. Worth a line in the app's session-load path too, for the same reason.
R2-2 — LOW/MEDIUM. import on a 3-column prose file now silently discards the third column
The fix routes such a file to the text parser instead of storing prose as an image — right call — but the fall-through drops the column rather than importing it:
$ printf 'manzana\tapple\tuna manzana roja\n…' > anki3col.tsv
$ loopky import anki3col.tsv --title "…" --separator tab --json
→ cards_written: 3, ok: true
$ loopky card list … --json
→ front 'manzana' / back 'apple' / image None ← "una manzana roja" is gone
0d5707b's rationale is that import is more forgiving "since there the third column is content somebody wants imported" — but it is not imported, it is dropped, and --json reports unqualified success. That is a quieter version of the same class the commit set out to close, and it contradicts readCardFile's stated contract, quoted in that very commit: "a file that produced fewer cards than it has lines is exactly the kind of loss --json exists to make visible." Here it is not fewer cards, it is less of each card, with nothing reporting it.
Suggestion: either append the extra column to the back (what "content somebody wants imported" would mean), or report columns_dropped: 1 in the import result. Silently is the one option that does not fit the rest of this surface.
R2-3 — the security addendum is unaddressed
None of S1–S6 from the second comment appear in this round. Not raising them again in full, but the two that are more than documentation:
- S1 —
--qr-outwrites the PNG at default umask (-rw-rw-r--observed) and never removes it. That file is thepubkyauth://secret; a QR is an encoding, not a protection.TerminalQr.writePngshould do whatJsonFileStore.restrictToOwnerdoes, and unlink aftercomplete(). - S3 —
logoutrefuses whenLOOPKY_SESSIONis set, sopubky.signOut(secret)is unreachable for exactly the credential the README tells you to carry into a sandbox. There is currently no way to revoke an exported session from this tool.
Notes on the fixes themselves
Read them all; the reasoning is sound and the two new test files go through the real shapes rather than hand-built ones — ApkgReaderJvmTest around a real zip and a real SQLite file, IdentityRepositoryImplTest through beginSignIn().complete() and adoptSession() with raw payloads. That was the actual defect in both cases, so testing the shape is the right correction.
Three small observations, none blocking:
23ddbbfmovingstartCliinside thetryis the better half of that fix — theNative.loadfailure happens during Koin resolution, so catchingThrowablealone would not have covered it. Worth noting thefinallynow swallows astopClifailure entirely; that is right here, but it means a Koin teardown bug would be invisible.isComment()matches on the untrimmed line, so# headeris not a comment. Conservative and consistent with treating the tab layout literally — just worth knowing it is deliberate.looksLikeImageUrlis scheme-only by design, which is correct, and means adata:orpubky://ref in an image column is now refused. No current path produces one, so this is a note rather than a finding.
🤖 Generated with Claude Code
Round 3 —
|
| Round | HIGH | MEDIUM | Status |
|---|---|---|---|
| 1 | 2 | 5 | all fixed and verified live |
| 1 (security) | 0 | 3 | open — S1, S3 unaddressed; S2 open |
| 2 | 0 | 2 | open — R2-1 stored-session migration, R2-2 dropped column |
| 3 | 0 | 3 | open — this comment |
Still finding MEDIUMs, so I'll keep going: pulling new commits as they land, re-running the affected paths on staging, and reporting each round here. Nothing above blocks the branch's core claim — the import worked, the deck is on the homeserver, the apps can open it — but --resume is currently a feature that costs more than it saves on exactly the imports it was built for.
🤖 Generated with Claude Code
Routing a 3-column prose file to the text parser rather than storing the third column as an image URL was the right call, but the fall-through *drops* that column — spec §8 keeps fields 0 and 1 — and `--json` reported unqualified success. So the import succeeded and every card was missing something the file held. That is a quieter version of the class the previous commit set out to close: not fewer cards, less of each card, with nothing reporting it. It also contradicts `readCardFile`'s own contract, quoted in that commit — "a file that produced fewer cards than it has lines is exactly the kind of loss `--json` exists to make visible". Reported rather than repaired. Appending the stray column to the back would change what the card says, and diverging from the app's parser is the one thing this command must not do; `columns_dropped` in the result and a line on stderr say what happened and leave the decision with the caller. Found in review by jvsena42 on #208 (R2-2), reproduced live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
One pass over session and credential handling on #208, six findings. They share files (`Identity.kt` spans the login and logout halves) so they land together. **`--qr-out` wrote a live bearer credential at the ambient umask and never removed it** (observed `-rw-rw-r--`). That PNG *is* the `pubkyauth://` URL, `secret=` included, and a QR code is an encoding rather than a protection — anyone who reads it before approval can poll the relay and take the session instead of the legitimate client. The flag exists for a headless box, so `--qr-out /tmp/qr.png` on a shared host is the *intended* use. Now created 0600 before a byte is written, the way `JsonFileStore.persist` does it, and deleted when the command ends. Two things there only showed up by running it. `ImageIO.write(…, File)` deletes and recreates the file, throwing the mode away — so the write goes through a stream on the file we created. And a `finally` is not enough: `login` blocks on the relay for as long as it takes somebody to reach for their phone, so the ordinary way it ends is **^C**, which takes the JVM down without unwinding. A shutdown hook covers it; verified by SIGTERMing a waiting login. **The plaintext auth URL went to stderr unconditionally** — the stream an agent harness captures into a transcript that may be logged or pasted into an issue. `redactAuthUrl` exists for exactly this value and was applied to `Log.d` on the same path while the line beside it printed it in full. It cannot be redacted, so it is printed only under `--url-only` where it is the deliverable, with a line saying it is a credential until approved. **An injected session had no revocation path at all.** `logout` refused when `LOOPKY_SESSION` was set, on the true but irrelevant grounds that nothing is stored to clear — sign-out does two things and only the local half was missing. A secret minted with `login --export` and carried into a sandbox stayed live until it expired. `revokeSession` is that path, and it is deliberately not `signOut`: on a developer's machine both credentials exist at once, and `signOut` would revoke the injected one while wiping the stored one. **`signOut` reported success when server-side revocation had failed** — the result was discarded. Clearing locally regardless is right, a user asking to sign out should end up signed out here whatever the network is doing; reporting it as an unqualified success while the bearer token is still live is not. It now returns `SignOutOutcome` and the CLI says which half happened. **The homeserver backfill did not reach sessions already on disk.** Applied in `complete()` and `adoptSession()` but not `loadPersistedSession`, so the same session behaved two ways depending on which door it came through — read from `secrets.json` the environment guard did not fire, injected as `LOOPKY_SESSION` the identical secret was correctly refused. Every account signed in through Ring before that fix has the blank, on both apps as well as the CLI, nothing prompts them to sign in again, and `session_live` says it is healthy. Now re-resolved on load and **persisted**, so the DHT is asked once. **`--export --json` warns** that it is about to put a live secret on stdout, and `LOOPKY_SESSION`'s exposure is documented beside the file store's — it is the weaker of the two channels and the one the README recommends more strongly: readable through `/proc/<pid>/environ`, inherited by every child, and in shell history when set inline. Found in review by jvsena42 on #208 (S1–S6, R2-1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
|
Round 3. All of R2-1, R2-2 and S1–S6 are addressed, and I owe you an apology for the addendum: my earlier reply truncated before it and I answered only the functional comment. Nothing in it was disputed, it simply went unread. R2-1 — the guard was still open for every install that existsYou are right that this is the important one, and the framing is worse than "the fix missed a path": for existing installs it fails open with no symptom at all, because
S1 — the QR fileFixed, and the fix was wrong twice before it was right. Both only surfaced by running it:
S3 + S4 — revocation
S4 too: S2, S5, S6, R2-2
On your three observationsAll three are deliberate, and worth confirming rather than leaving implied:
Verified44 CLI tests, 1,296 shared, Still not verified by me: anything past sign-in. Your two staging runs remain the only end-to-end evidence on this branch. 🤖 Generated with Claude Code |
Two things found by running the command I had just changed, both the same shape as the review's S4 — a report that claims more than happened. **`logout` with no session at all answered `revoked: true`.** `signOut` treated "there was nothing to revoke" as a successful revocation, so a fresh machine was told a credential had been withdrawn that never existed. Sign-out being idempotent is right; saying it revoked something is not. `SignOutOutcome` now carries `hadSession`, and the three states read differently: "Not signed in — nothing to revoke", "Signed out", and "Cleared this machine's session, but the homeserver did not confirm it was revoked". **A typo'd `LOOPKY_SESSION` came back as `session_expired` from `logout`.** `requireSession` checks the secret's shape and `logout` is the one command that does not go through it, so the check reached every command except the one whose whole job is to handle that credential. Exit 9 now, as everywhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
Round 4 —
|
| Evidence | |
|---|---|
| S1 QR file mode | stat on a live --qr-out: -rw------- 20508 r4-qr.png — was -rw-rw-r-- |
| S1 QR file removal | sent SIGINT to a waiting login (the ^C case, not the SIGTERM the commit message cites): process exits 130, file gone. The shutdown hook does cover the common path |
| S2 plaintext URL | default login stderr: no pubkyauth:// anywhere in it. --url-only prints it under "This URL is a credential until you approve it in Ring — treat it like a password." |
| R2-1 stored-session heal | whoami from the file now reports homeserver: ufibwbmed6…, and secrets.json on disk was rewritten with it — asked once, not per invocation |
| R2-1 the guard closes | whoami --env production from the file path is now exit 8 environment_mismatch. In round 2 the identical command was exit 0 with a blank homeserver. This is the whole point of the guard, working end to end for the first time |
| R2-2 dropped column | columns_dropped: 1 on the 3-column prose file, 0 on a clean 2-column one; the human channel adds "DROPPED a third column on every row…" |
| S4/S5/S6 | SignOutOutcome reports the two halves separately, --export warns before printing, the README documents /proc/<pid>/environ beside the file store. Code-verified; S4's failure branch needs a broken network to exercise |
R4-1 — MEDIUM. logout bypasses the session-shape check, so the exit-4 bug is back on the new path
$ LOOPKY_SESSION=garbage loopky logout --env staging --json
{"ok":false,"error":{"code":"session_expired","exit":4,
"message":"…invalid secret: expected `<pubkey>:<cookie>` LOOPKY_SESSION is no longer valid;
mint a new one with `loopky login --export`."}}
rc=4
This is finding 8 from round 1, reintroduced. 0d5707b fixed it with requireSessionSecretShape, but that function is private in Runtime.kt and reachable only from requireSession. The new logout reads LOOPKY_SESSION itself (Identity.kt:238) and goes straight to identity.revokeSession(injected), so nothing checks the shape — a parse failure comes back as an expiry, telling an agent to mint a new session when the actual problem is a typo'd environment variable.
Worth noting why it happened, because it is structural rather than careless: the fix for S3 added a second door into the injected-session path, and the validation lives on the first door. A third one will do the same.
Suggestion: make requireSessionSecretShape internal and call it from logout, or better, put it inside revokeSession/adoptSession in the repository so every caller inherits it. The check is about the value, not about who is asking.
(A well-formed but dead secret correctly returns exit 4 — "Authentication error: The provided auth request has expired or was cancelled". That one is right.)
Not tested, and why
The successful revocation path. LOOPKY_SESSION=<real secret> loopky logout would revoke the session this review has been running on, costing another QR scan. I exercised both failure branches instead. Say the word and I'll run the real one — it is the last untested branch of S3.
Still open from round 3 — import --resume
Untouched by these commits, and round 4 turned up more evidence for the first one. Deleting 59 cards one at a time from a 150-card deck took 4m42s — ~4.7s per card, against 4.7s for publishing all 150 at once. That is the same per-card write path appendMissing uses, and it confirms the cost is not linear in cards but in cards × manifest size.
- R3-1 —
--resumewrites per card (2 homeserver writes each) instead of per chunk; measured 40.7s vs 4.3s for the same 30 cards, and worse as the deck grows. The recovery path for a session that ran out of time is ~10× slower than the attempt that ran out of time. - R3-2 — a typo'd
--titleon a--resumerun silently creates a second deck;resumed: 0is indistinguishable from a legitimate first run. - R3-3 — every metadata flag on a resumed run is accepted and discarded,
--front-lang/--back-langincluded, so Listen and Speak silently never appear.
Also checked this round, and correct
deck compact on a real two-chunk deck: 150 cards → 59 deleted → {"merges":1,"cards_moved":50,"chunks_before":2,"chunks_after":1,"complete":true} in 5.1s, and all 91 remaining cards came back intact, in order, no duplicates, no nulls. The landing-record-then-manifest-then-source ordering the architecture doc warns about holds up under a real merge.
One observation rather than a finding: deck show --json does not expose the chunk table, so compact's effect is invisible through the CLI's own verification channel except via the counts in its own result. For a command whose entire job is rearranging chunks, chunks on DeckView would let a caller check the work.
| Round | HIGH | MEDIUM | Status |
|---|---|---|---|
| 1 | 2 | 5 | fixed, verified |
| 1 (security) | 0 | 3 | fixed, verified |
| 2 | 0 | 2 | fixed, verified |
| 3 | 0 | 3 | open |
| 4 | 0 | 1 | open — this comment |
Still finding MEDIUMs, so the loop continues.
🤖 Generated with Claude Code
|
Two more, self-found while re-testing the round-3 fixes on the binary. Both are the S4 shape — a report claiming more than happened — so flagging them rather than letting them ride to the next round.
Sign-out being idempotent is right; saying it revoked something is not. A typo'd
🤖 Generated with Claude Code |
…esume
**`card add` computed an ord and threw it away.** `upsertCard` assigns one from
the chunk the card lands in and ignores whatever the caller sent — so the
`existing.maxOfOrNull { it.ord } + ORD_STRIDE` here was dead weight that *looked*
meaningful, and it is exactly how the command came to report a number the
homeserver never stored. Removing it means there is no longer a second opinion
about ordering for a reader to trust. `deck create` still assigns ords, and
correctly: `publish` writes the chunks directly rather than through `upsertCard`,
so there they are used.
**`import --resume` picked arbitrarily between decks sharing a title.**
`firstOrNull` over a listing in homeserver order would append somebody's cards to
whichever came back first — a silent wrong answer, and the resume mechanism
exists precisely so a killed import does not duplicate or misplace work. It now
refuses and names the candidates, which the user can act on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
Round 5 —
|
| Evidence | |
|---|---|
R4-1 shape check on logout |
LOOPKY_SESSION=garbage loopky logout → exit 9 bad_input, was exit 4 |
self-found: logout with no session |
isolated LOOPKY_CONFIG_HOME: {"revoked":false,"cleared_locally":false}, "Not signed in — nothing to revoke" |
card add ord |
fresh deck: echoed 0, stored 0. Dropping the dead computation is the better fix — there is no second opinion left to disagree |
| ambiguous resume | two decks titled ZZ dup title → exit 9, naming both: "…cannot tell which one to continue: 9wzjykxj50gw, grxqxihluih2. Rename or delete one, or drop --resume" |
S3 — the revocation branch, run for real
$ LOOPKY_SESSION=<the live secret> loopky logout --env staging --json
{"ok":true,"data":{"revoked":true,"cleared_locally":false}} rc=0
$ loopky whoami --env staging --json → "session_live": false
$ loopky deck create --title … --json → exit 4, "Run `loopky login` again."
It genuinely revokes: the homeserver stops honouring the secret, and the next write fails with the right code and the right advice. That was the last untested branch of the credential work.
Also confirmed at its source
A fresh Ring sign-in now reports Homeserver: ufibwbmed6… on the login line itself — previously blank, which is where this whole thread started. And --env production against the new session is refused with exit 8. So the backfill is verified at all three doors now: complete(), adoptSession() and loadPersistedSession().
The --qr-out file was deleted automatically when approval landed, without a signal — the finally path, complementing the shutdown hook I tested with SIGINT last round.
R5-1 — LOW. logout on an injected session asserts something it never checks
$ LOOPKY_SESSION=<secret that is ALSO in secrets.json> loopky logout
"Revoked the session from LOOPKY_SESSION. Nothing was stored on this machine,
so there is nothing here to clear — unset the variable too."
Nothing was stored is an assumption, not a check. In the run above the identical session was stored here — which is exactly what happens when a secret is minted with login --export on the machine it is later revoked from, the flow the README describes. So the message is false, and a dead credential is left installed in secrets.json: the next whoami still returns ok: true and only session_live: false hints at it.
The KDoc's reasoning is right — signOut would revoke the injected credential while wiping a different stored one — but it assumes the two differ. Comparing the injected secret against the stored one is one line and lets the message say which case it is in, and clear the local copy when they match.
Same family as S4 and the hadSession bug in 2508914: a report claiming more than it knows. Low because writes fail with correct advice and the next login heals it.
Still open — import --resume, all three unchanged
Re-measured on the current head:
- R3-1 (MEDIUM) — 30 resumed cards: 40.2s, against 4.3s to publish the same 30 fresh.
appendMissingstill loopsupsertCard, so 2 homeserver writes per card againstpublish's 2 per chunk. The recovery path for a session that ran out of time remains ~10× slower than the attempt that ran out of time, and worse as the deck grows. - R3-2 (MEDIUM) — the main case is untouched.
09645adfixed the ambiguity between two decks sharing a title; a title matching nothing still falls through and publishes a new deck:--title "ZZ nonexistent title" --resume→ deck created,written 10,resumed 0, rc 0. A typo still costs a duplicate deck, andresumed: 0is still indistinguishable from a legitimate first run. Aresume_matchedfield, or refusing when--resumefinds no candidate, closes it the same way the ambiguity case was closed. - R3-3 (MEDIUM) —
--tag beta --description "set on resume"on a resumed run: deck came backtags: ["alpha"],description: null, rc 0.--front-lang/--back-langgo the same way, so Listen and Speak silently never appear on a deck finished by a resume.
| Round | HIGH | MEDIUM | LOW | Status |
|---|---|---|---|---|
| 1 | 2 | 5 | 4 | fixed, verified |
| 1 (security) | 0 | 3 | 3 | fixed, verified |
| 2 | 0 | 2 | 0 | fixed, verified |
| 3 | 0 | 3 | 1 | 1 LOW fixed; 3 MEDIUM open |
| 4 | 0 | 1 | 0 | fixed, verified |
| 5 | 0 | 0 | 1 | this comment |
First round with no new HIGH or MEDIUM. The branch is in good shape — the credential handling in particular went from six findings to none across two rounds, and the parts that were hardest to get right (the QR file's mode surviving ImageIO, the shutdown hook, revocation without clobbering a co-resident session) are all correct under test. --resume is the one area still carrying real findings.
🤖 Generated with Claude Code
"Nothing was stored on this machine" was asserted, not checked — and it is false in the flow the README itself describes. A secret minted with `login --export` *is* the stored session, so revoking it left a dead credential installed here: `whoami` still answered `ok: true`, with only `session_live: false` to hint at it. The reasoning for routing this through `revokeSession` rather than `signOut` still holds — `signOut` would revoke the injected credential while wiping a *different* stored one — but it assumed the two differ. Comparing them is one line, and it lets the message say which case it is in and clear the local copy when they are the same session. Same family as S4 and the `hadSession` bug: a report claiming more than it knows. Found in review by jvsena42 on #208 (R5-1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
…nothing Two of the three `--resume` findings. **Deck metadata passed to a resumed run was accepted and dropped.** `newDeck` is only reached on the fresh path, so `--tag`, `--description`, `--cover-url`, `--cover-emoji`, the four study opt-ins and `--front-lang`/`--back-lang` were all taken and ignored, with success reported. The natural way to use the feature is to re-run the *same command* with `--resume` appended — which is what the README shows — so an agent whose deck got half-written lost every flag but `--title`. The language pair is the sharpest: dropped means `speechReady` stays false and Listen and Speak never appear on a deck finished by a resume. Only what was actually given is overlaid, which is why the opt-ins go through the new `Args.flagOrNull` rather than `Args.flag`: absent and "explicitly false" are the same to `Boolean`, and treating them alike would turn off every mode the deck already had on a bare `--resume`. **A `--resume` that matched nothing was indistinguishable from a first run.** One transposed character in `--title` and the result is a second full deck, quota spent twice, `resumed: 0` — exactly what a legitimate first run reports. Reported rather than refused, which is the other option the finding offered. Refusing would break the idiom the flag is most useful for: an agent that always passes `--resume` so its retries are safe still has to be able to make the first run. So `resume_matched` is a distinct field, and the human path names the decks that do exist, which is what makes a typo visible. Found in review by jvsena42 on #208 (R3-2, R3-3). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
`import --resume` looped `upsertCard`, and each of those is a chunk write **plus** a full manifest read-modify-write — 60 homeserver writes for 30 cards where `publish` spends 2. The manifest carries the whole chunk table, so the per-card cost climbs with deck size. Measured on staging: 30 cards took 40.7s to resume against 4.3s to publish the same 30 fresh. That is not a performance footnote. `--resume` exists so an import killed by the hourly session expiry (#165) can finish, and a recovery an order of magnitude slower than the attempt that ran out of time means a large import never finishes — each retry dies earlier in the file. The mechanism made the failure it exists for more likely. `DeckRepository.appendCards` is the batched path: fill the last chunk with room, add trailing chunks, describe the lot in a **single** manifest patch — which is what `publishLocked` has always done, via the `writeChunksAndManifestLocked` it already had. The resume path was the only caller doing it the expensive way. The tests assert the *number* of manifest writes, since the count was the defect. Writing them found a second bug in the first draft. When the last chunk is full the append target is a chunk that does not exist yet, so the tail read comes back empty and there is nothing local to continue ords from; the fabricated floor I had put appended cards *before* ones already in the deck, silently scrambling study order for exactly the decks big enough to have filled a chunk. It now reads the previous chunk — one extra read, only at the boundary. `CardChunking.planAppend` holds the arithmetic, next to `appendTarget` where the single-card version lives. `DeckRepositoryImpl` crossed detekt's `LargeClass` threshold with the addition; the exclusion is named and reasoned in `config/detekt/detekt.yml` rather than the threshold raised, and it records what the real seam is and why taking it is a different decision. Found in review by jvsena42 on #208 (R3-1), measured live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
|
Round 5 answered, and round 3 too — I never received it. My reply that round went out at 10:44 and your round-3 comment landed at 10:41; I read the thread from a stale fetch and missed it entirely. That is why three MEDIUMs sat open for two rounds with no acknowledgement. Sorry. All four are fixed: R3-1 — the batched appendYou were right that this is the one that matters, and the framing is the reason: the recovery being slower than the thing it recovers means a large import cannot finish, and each retry dies earlier in the file. A feature that makes its own failure mode more likely.
Writing those tests found a second bug in my first draft, and it is worse than the one I was fixing. When the last chunk is full, the append target is a chunk that does not exist yet — so the tail read comes back empty and there is nothing local to continue ords from. My fabricated floor put appended cards before ones already in the deck, silently scrambling study order for exactly the decks big enough to have filled a chunk. It now reads the previous chunk: one extra read, only at the boundary, with a test that seeds R3-2 — I took your other option, deliberatelyYou offered refuse-with-exit-6 or report-a-field. I took the field, and want to be explicit about why rather than have it look like the cheaper choice: refusing breaks the idiom the flag is most useful for. An agent that always passes So R3-3 — metadata on the resume pathApplied via The subtlety worth flagging: only what was actually given is overlaid. Absent and "explicitly false" are the same to R5-1Fixed, and your reading was right: the KDoc's reasoning held but assumed the two secrets differ. Compared now, cleared when they match, and the message says which case it is in. One thing I did that you should look at
The exclusion records what the real seam is: the "following someone else's deck" block, ~100 lines. I did not take it because Verified49 CLI tests, 1,304 shared (8 new), I have not re-measured the resume timing against staging; I still have no Ring session. The write count is asserted in tests, but the 40.7s → expected ~2 writes is your measurement to confirm. 🤖 Generated with Claude Code |
Round 6 —
|
| Round | HIGH | MEDIUM | LOW | Status |
|---|---|---|---|---|
| 1 | 2 | 5 | 4 | fixed, verified live |
| 1 (security) | 0 | 3 | 3 | fixed, verified live |
| 2 | 0 | 2 | 0 | fixed, verified live |
| 3 | 0 | 3 | 1 | fixed, verified live |
| 4 | 0 | 1 | 0 | fixed, verified live |
| 5 | 0 | 0 | 1 | fixed, code-verified |
| 6 | 0 | 0 | 1 | this comment |
Six rounds, 2 HIGH and 14 MEDIUM found and closed, every one of them re-tested against a real staging homeserver rather than signed off from the diff. Two rounds in a row with no MEDIUM, and nothing outstanding above LOW. From my side this is done.
Worth recording what the exercise actually turned up, because it was not evenly distributed:
- Two defects had passing tests sitting over them.
EnvironmentMismatchTesthanded the guard a homeserver by hand, so a safety check that had never once fired in production use was green; and the.apkgsuite drove the reader's callers rather than its opener, so a JDBC URL that was never interpolated went unnoticed. Both now have tests that go through the shape the real flow produces. - The environment guard had never fired. Not once, in any session the CLI could mint, until round 4. That is the finding I would keep if I could keep only one.
- Credential handling went from six findings to zero across two rounds, and the fixes were better than the reports —
ImageIO.write(…, File)silently recreating the file at the ambient umask, and^Cbeing the normal wayloginends so afinallyis not enough, were both found by running it rather than reading it. - The last three fixes each found a further bug while being written: the append boundary scrambling study order,
logoutclaimingrevoked: truewith no session, and absent-vs-false on the resume opt-ins. That is the pattern of a change being tested rather than asserted.
The deliverable that drove all of this is still on the homeserver and reads back clean: avskolydfy2q — "Computer Networks — Ch. 1: Introduction", 48 cards, tagged networking / tanenbaum / computer-science, ords ascending, extracted from chapter 1 of Tanenbaum, Feamster & Wetherall 6e and published entirely through loopky. Which was the point: an agent made a deck without a phone screen.
🤖 Generated with Claude Code
`updateMetadata` re-reads `chunks` and `cardCount` inside its lock but writes the `updatedAt` it is handed — and the resume path handed it a snapshot taken *before* the append. So describing the deck rewound the manifest's timestamp below what the append had just set. `hasUpdate` compares exactly that field against a follower's `last_seen_updated_at`, so a follower who had already seen the deck would never be told the new cards exist. The cards were on the homeserver and invisible to everyone syncing it. Found while re-reading my own R3-3 fix rather than by a test, which is the uncomfortable part — the sequence is append-then-describe and only the second half carries a timestamp. `DeckRepositoryAppendTest` now pins the contract from both sides: what a caller must not do with a stale snapshot, and what carrying the post-append timestamp gives instead. Also drops a `cardCount` from that call that read as if it mattered; it is overwritten inside the lock either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
…er --json `resumeState`'s "found no deck titled …" note went through the `progress` sink, which is suppressed under `--json` — so the one message that turns "no match" into "you meant *this* one", by naming the account's existing deck titles, was withheld in exactly the mode where a typo does its damage. `resume_matched: false` was in the envelope, so an agent was not blind, but it could not tell which deck it meant. The root of it is that one lambda was doing two jobs. A **progress counter** is thousands of lines on a large import and noise in a scripted run, and the result carries the same numbers, so suppressing it under `--json` is right. A **note** is something the caller needs to know, and stderr is the diagnostic channel in either mode — the README describes stdout and stderr as separate channels, not one that switches off, and an agent capturing stderr must not get an empty file because it asked for JSON. Two sinks now, and the README says which is which. Found in review by jvsena42 on #208 (R6-1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
|
R6-1 fixed in You identified it as a routing mistake and it was worse than that — one lambda was doing two jobs, and I had not noticed they were different jobs:
Two sinks now, so this is fixed for any future note rather than just this one. Your README point was the load-bearing half of the finding: it describes stdout and stderr as separate channels, not one that switches off, and an agent capturing stderr for diagnostics should never get an empty file because it asked for JSON. The README now says which sink Code-verified, not run — same position you took on R5-1, and for the same reason: the note fires after On the summary: the two findings with passing tests over them are the ones I would keep too, and they were the same mistake twice — a test built on a fixture rather than on the shape the real flow produces. The environment guard never having fired in any session the CLI could mint is the sharper of the two, because it was this PR's own headline safety feature and I had written a test file for it. The three fixes that each found a further bug while being written is the pattern I would want on the record. In every case the second bug was worse than the first: the append boundary silently scrambling study order only on decks large enough to have filled a chunk; Twenty findings across six rounds, every one re-tested against a real homeserver. Thank you for that; it is the half of the work I could not do, since I have never held a Ring session on this branch. If nothing else is outstanding, please approve. CI is green on Everything past sign-in on this branch rests on your runs. 🤖 Generated with Claude Code |
Round 7 — the LOW findings, and a sweep of the paths no round had touchedAsked to close out the LOWs. Every one from rounds 1–6 was re-checked against R6-1 — fixed, verified
The note gets through under The rest of the LOWs, re-checked at HEAD
Two were verified by reading rather than running, and both need a condition I cannot make cheaply: S4's failure branch needs the homeserver to refuse a revoke, and R5-1's match branch reaches its message only after revoking, so testing it costs a QR scan. Both fixes are short and directly answer the finding. Happy to spend a sign-in on R5-1 if you want it on the record. The one genuinely open item is the round-4 note, which I raised as an observation rather than a finding and still would: Sweep of the untested paths
R7-1 — MEDIUM.
|
| Round | HIGH | MEDIUM | LOW |
|---|---|---|---|
| 1 | 2 | 5 | 4 |
| 1 (security) | 0 | 3 | 3 |
| 2 | 0 | 2 | 0 |
| 3 | 0 | 3 | 1 |
| 4 | 0 | 1 | 0 |
| 5 | 0 | 0 | 1 |
| 6 | 0 | 0 | 1 |
| 7 | 0 | 1 | 0 |
| total | 2 | 15 | 10 |
Every LOW closed but the DeckView chunk-table note, and one new MEDIUM on card rm. Nothing else in the sweep misbehaved.
🤖 Generated with Claude Code
…nk table **`card rm` answered identically whether it deleted a card or did nothing.** Same envelope, same exit 0, for a real id and a nonexistent one — so an agent pruning a list of ids could not tell which of them were real without re-reading the deck between every delete. `deleteCard` treats a missing card as a no-op and the CLI reported that as success. Fixed with a `removed` count rather than an exit code, and the asymmetry with `card edit` — which does return `not_found` — is the deliberate part. Removing a card that is already gone leaves the deck in the state the caller asked for, so failing it would break the retry-after-expiry pattern the whole surface is built around; that is the same reason `card add` reports `skipped` instead of refusing a duplicate. An *edit* has no such reading: the change cannot be applied at all. So: add and rm are idempotent and say what they did, edit is not and says why. Knowing the count takes one manifest read before the delete. That is one record, not the deck's cards — `cardCount` comes from the chunk table. **`DeckView` now carries that chunk table.** `deck compact`'s entire job is rearranging it, and without it the effect was invisible through the verification channel except as counts in compaction's own result: a caller could only take the command's word for the work. `deck compact` re-reads the deck afterwards and returns it. `chunks[].updated_at` is also what a follower diffs to re-fetch one record rather than a whole deck. The numbering is **not contiguous** — compaction folds a pair and drops the higher `n` — and there is a test that a gap survives the view, because renumbering into a dense list is the obvious way to get this wrong. Found in review by jvsena42 on #208 (R7-1, and the round-4 note). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
|
Both closed in R7-1 — fixed, but I took the
|
| already in the desired state | reports | |
|---|---|---|
card add |
card already present | skipped, exit 0 |
card rm |
card already absent | removed: 0, exit 0 |
card edit |
— | not_found, exit 6 |
edit is the odd one out correctly: there is no reading under which editing a card that does not exist leaves the deck as asked. Add and rm are idempotent and say what they did; edit is not and says why. Your finding was that card rm said nothing, and that part was exactly right — the information was in hand and thrown away.
It costs one manifest read before the delete. That is one record rather than the deck's cards, since cardCount comes from the chunk table.
The round-4 note — chunks on DeckView
You were right to keep raising it. A command whose entire job is rearranging the chunk table, reporting its effect only as counts in its own result, is the one place on this surface where a caller has to take the command's word — which is the opposite of what --json is for. deck show and deck compact both carry the table now, and compaction re-reads the deck afterwards so the result is the check.
One thing worth flagging since it is a trap I nearly walked into: the numbering is not contiguous. Compaction folds a pair of neighbours and drops the higher n, so anything walking it must read n rather than 0 until size. There is a test that a gap survives the view, because renumbering into a dense list is the obvious way to get this wrong and it would look correct.
On S4 and R5-1
Do not spend a sign-in on R5-1 on my account — you have already spent two, and a LOW whose logic is six lines is not worth a third. If you want one of them on the record I would rather it were S4, the failed-revoke branch: it is the one where the user is told "Signed out" while the token is still live, and it is the only untested path that could still mislead someone about a credential.
Verified
52 CLI tests, 1,304 shared, :shared:allTests, :composeApp:assembleDebug, detektAll — green. CI green on the previous head across all three jobs and running on this one.
Seven rounds, 2 HIGH / 16 MEDIUM / 10 LOW, every one of them re-tested against a real homeserver. The three findings I would keep: the environment guard that had never fired in any session the CLI could mint, and the two defects with passing tests sitting over them — both the same mistake, a test built on a fixture rather than on the shape the real flow produces.
Please approve if nothing is outstanding.
🤖 Generated with Claude Code
Round 8 — clean. Nothing open.
The asymmetry argued for in that commit message is the right call, and worth keeping the reasoning for: This is my sign-off. Seven rounds, all against a live staging homeserver. (An approving review is not possible here — the only account on this machine is the one that opened the PR, and GitHub refuses Final tally
All fixed. Every one re-tested against a real homeserver rather than signed off from the diff. What I would keep from the exercise
Two corners still untested, deliberatelyBoth need a condition that cannot be created cheaply, and both fixes are short and directly answer their finding:
Worth a line in The deliverable
🤖 Generated with Claude Code |
Eight rounds against a live staging homeserver, 2 HIGH / 15 MEDIUM / 10 LOW closed. The round-by-round detail lives on #208; what belongs in RESULTS.md is what a later reader needs. Two things worth keeping. **Two defects had passing tests over them, and it was the same mistake twice** — a test built on a fixture rather than on the shape the real flow produces. That cost a safety check which had never once fired, and an `.apkg` reader that had never opened a file. The rule that falls out of it is written down: test a check through the path that mints the value. And the **untested corners are named**, at the reviewer's suggestion, so nobody reads the rest of the file as meaning the whole surface has been driven. Both need a condition that cannot be made cheaply: a homeserver that refuses a revoke, and a fresh sign-in to reach `logout`'s same-session branch. The first is the one path left that could still tell someone "Signed out" while their token is live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXjHov9XSqWtJg8AiqKpBQ
|
Taken as the approval — thank you. Eight rounds against a real homeserver is the half of this I could not do, and it found things a green build was never going to. Your last suggestion is in as The branch is ready to merge. What is landing, and what is not:
🤖 Generated with Claude Code |
|
Follow-ups filed, so the open work survives #54 closing rather than living in a comment thread.
I did not file the Windows stays out of scope by decision, recorded in #54 and §13.10. 🤖 Generated with Claude Code |
Closes part of #54: the Linux x86_64 row, plus everything cross-platform it needs.
Fork side: jvsena42/pubky-core-ffi-fork#4 (the desktop cdylibs). Its artifacts are vendored here, so this branch builds without it.
What this is
loopky— a terminal binary that creates and manages decks against the same homeserver layout the two apps use. The point is not a nicer keyboard UX: it is that an agent can drive Loopky. Agents already write good flashcards; until now the only way into a Loopky deck was a phone screen.Six commits, each standing alone.
1.
:sharedgrows ajvm()targetMost of what a desktop JVM needs was already written — it was just filed under
androidMainby accident of Android having been first.applyDefaultHierarchyTemplategrows ajvmSharedgroup and the plain-JVM code moves into it: thejava.time/java.securityactuals, the zip helpers,HttpURLConnection, and the UniFFI JNA bindings.Sharing rather than copying is the point.
uniffi/pubkycore/pubkycore.ktis generated in the fork and checked in here; a second copy underjvmMainwould have to stay byte-identical forever with nothing reporting it when it stopped.The group is added through the template rather than with bare
dependsOnedges — those switch the default template off, and the only sign is a warning sayingiosMainno longer belongs to a compilation.kvaultmoves out ofcommonMain(it publishes android + iOS artifacts and nothing else). The.apkgreader splits the same way: everything but opening the collection becomesJvmApkgReaderbehind anAnkiDbOpener, so Android keeps platform SQLite and the desktop takessqlite-jdbc.2–3. The native library, and the platform module
libpubkycoreships inside the jar under JNA's resource layout, so nobody installing the CLI fetches a native library by hand.UniffiPubkyClientJvmTestis the part with teeth: the 1,271 shared tests run against a fake client and would pass identically on a machine where that directory is empty or the architecture is wrong — a failure that otherwise surfaces hours later as an ordinary-looking transport error.The seven stores go to 0600 JSON files under
$XDG_CONFIG_HOME/loopky. The file is the default, not a fallback, and that is the decision: libsecret is usually present on a desktop Linux and usually absent on the headless box an agent runs on, so a keyring default would fail exactly where the tool is meant to work.4–6. The CLI
Three properties everything is arranged around.
The session scope is
/pub/loopky/:rwand nothing else — not the apps'DEFAULT_CAPABILITIES. An agent session cannot write a post, a follow or a profile edit under any bug or any prompt injection, because it was never handed the capability. The announce confirmation (#39) is gone by construction, not behind a flag: there is nothing to confirm. It costs less than it looks, because deck tagging routes on the subject and a deck manifest's tag record lives in/pub/loopky/tags/.That scope also turned up a real bug in shared code:
selfTagAsLoopkyUsertags a profile, so its record goes to/pub/pubky.app/tags/— and it fired on every session load, from a session that was never granted the namespace. Best-effort, so nothing broke; it just bought a doomed round trip per command and logged a warning about the scope working as designed.Session.canWritePubkyAppnow asks first.Nothing prompts, and stdout is a machine channel.
--jsonis a versioned envelope carrying the result and the failure, with the environment and the indexer on every one — because Nexus answers a query aimed at the wrong network successfully and empty, and an agent that reads[]concludes its write failed and retries. Exit codes distinguish not-signed-in from session-expired from network; expiry gets its own because it is hourly, unrecoverable without a human (#165), and otherwise indistinguishable from a wobble.Long jobs are resumable and writes are idempotent.
card addtwice with the same sides is detectable and skipped;import --resumecheckpoints against the deck on the homeserver rather than a local cursor, because the deck records exactly which cards arrived and survives the sandbox being thrown away. Batch--from-filefor add and edit, since editing is what an agent does after an import.The import format carries image URLs from day one —
front <TAB> back <TAB> front_image_url <TAB> back_image_url— because a remote ref costs no bytes and no quota since #167, and it is the one thing neither.apkgnor TSV-through-the-parser can express.Verified
On Linux x86_64, in CI — the branch's biggest unknown, since the
.sowas cross-built from macOS in a container and nothing had loaded it on a real glibc host:linux-x86-64/libpubkycore.soloads through JNA from the jar:shared:jvmTestgreen onubuntu-latest, andUniffiPubkyClientJvmTestis in itloopky 0.1.0 (schema 1);whoami --json→ exit 3 with"code":"not_signed_in"and"environment":"production"On macOS arm64, by hand, against the live production and staging networks:
:shared:allTests(Android + JVM + iOS sim),:composeApp:assembleDebug,detektAllloginmints a real auth URLcaps=/pub/loopky/:rw, nox-success/x-cancelcallbacks--qr-out+--url-onlyLOOPKY_SESSIONlogin --export", not "runlogin")Full table in
journeys/RESULTS.md.Two bugs the hand-run caught that a green build did not, both now covered by tests:
--versionprinted the usage block, andimport cards.tsvdispatched under the verb"import cards.tsv"— the verb was "the first two words", which is right fordeck createand wrong the moment a command takes an operand.Not verified — this is the part that needs your Linux box
No Pubky Ring approval was completed, because a QR needs a phone next to the machine. So everything above the sign-in line —
deck create,import,card add/edit/rm,deck sync/compact,whoamiagainst a real session — is untested end to end, and these acceptance criteria are unmet until someone scans the code:importkilled mid-run and resumed without duplicating a card/pub/pubky.app/, verified by reading it afterwardsTo try it there:
./gradlew :cli:installDist cli/build/install/loopky/bin/loopky login # scan with Ring cli/build/install/loopky/bin/loopky whoami --jsonKnown gaps, named as decisions
native-imageis the target (JNA's library extraction is the sharp edge),jlinkthe fallback, plus a container image. The tarball also carries both native rows, so a Linux box hauls 11 MB of macOS dylib.🤖 Generated with Claude Code
https://claude.ai/code/session_018nKWTtN24p1VqMSNkQkMMr