Commit Graph
245 Commits
Author SHA1 Message Date
jaap-jan 30a3edb1d4 Build the phone in a container, because this runner cannot build it at all
ci / build and test (push) Successful in 1m54s
ci / android head (push) Failing after 3m54s
ci / api image (push) Successful in 44s
The runner is Alpine, and .NET for Android does not work on musl. Not "needs setting up" — the SDK's
own MSBuild tasks pull glibc shared objects out of the workload pack into the build process, and a
musl-linked dotnet will not load one:

  error XARLP7000: Error relocating .../libZipSharpNative-3-3.so: __snprintf_chk: symbol not found

That is a glibc fortify symbol musl does not implement, reached through a DllImport rather than an
exec, so gcompat is no help: it gets a glibc *executable* started, which is a different problem. There
is no musl variant of the pack.

Everything this job did on the host to make Android work was therefore treatment of symptoms, mine
included. The missing aapt2 was present. The "unsupported version" was of a binary that had never run.
Both were this one sentence in a different accent, and the loader was the accent, not the sentence.

So the toolchain moves into build/android-build.Dockerfile — Microsoft's own sdk:10.0-noble plus a JDK,
the Android SDK and the workload — and the job keeps on the host only what the host is good at:
checkout, git, publishing. The daemon needed no arranging, since the image job already builds with it
and every Testcontainers suite reaches it over the socket. The image is tagged by the digest of the
Dockerfile that made it, so on a persistent runner every run after the first is a cache hit, and a
change to the toolchain is the only thing that buys a new one.

Built rather than pulled: a community image with the Android SDK already in it would put a stranger in
the path of a package this project signs and publishes. Eleven lines of apt and sdkmanager is the
cheaper trade.

Verified end to end in that image against a real clone rather than reasoned about, which after three
rounds of reasoning seemed the least I could do. Restore under locked mode, Release build, then
SignAndroidPackage:

  package: name='dev.dodotech.dodossh.nightly' versionCode='195' versionName='0.0.0-alpha.0.128'
  Signer #1 certificate SHA-256 digest: a9f067877724ddb0fdc04b637fbd5bfb97df753976616f100b48b522e132ba22

which is the keystore in build/. The versionName carries MinVer's height, so the csproj's target fires
in the container too, and the manifest the feed publishes parses back on the host.

Staging moves from RUNNER_TEMP to artifacts/, which is forced rather than preferred: the package is
made inside a container and read outside one, so it has to land under the bind-mounted checkout.
2026-08-04 23:28:47 +02:00
jaap-jan 65256fa337 Take the phone's aapt2 from the SDK the job installs rather than the workload's
ci / build and test (push) Successful in 1m49s
ci / android head (push) Failing after 7s
ci / api image (push) Successful in 30s
The android job got past restore and died in the .NET Android SDK's own tooling resolution:

  warning : An error occurred trying to start process
            '.../packs/Microsoft.Android.Sdk.Linux/36.1.69/tools/Linux/aapt2' … No such file or directory
  error XA0111: Unsupported version of AAPT2 found at path '.../tools/Linux'

The error names the wrong problem. Nothing was found, so nothing had a version, and XA0111 points at
an Aapt2ToolPath in the project file that has never been set. The warning above it is the real message
and it is only a warning.

The pack was incomplete, and on this runner it would have stayed that way: act's host executor keeps
/usr/share/dotnet between runs, and `dotnet workload install` reads the installed-workload records and
does nothing when android is listed, whatever is on disk. So two changes, each of which stands alone.

The build and the packaging step are now given -p:Aapt2ToolPath pointing at build-tools, which this job
installs itself and can therefore vouch for. That is already the aapt2 the packaging step shells out to
for `dump badging`, so this makes one tool of what were two, and the manifest the feed publishes is now
read by the binary that wrote it. Checked rather than assumed: build-tools 36.0.0 answers aapt2 2.20 and
.NET for Android 36.1.43 builds and packages this head against it with no complaint. The version is
named once, in the step's env, because three things now depend on it agreeing with itself.

And the workload step probes for the pack file that went missing and repairs the workload when it is
absent. The probe is a witness rather than the point — the build no longer touches that binary — but a
5 MB file near the end of a 130 MB package is what a truncated extraction loses first, and r8.jar and
manifestmerger.jar are what it loses next.
2026-08-04 22:32:52 +02:00
jaap-jan 7e0a1b2af8 Bring the phone's lock file back to the graph it actually restores
ci / build and test (push) Successful in 1m54s
ci / android head (push) Failing after 14s
ci / api image (push) Successful in 27s
The android job's restore has never been reachable — the runner had no JDK and no SDK, so it failed
before it got there — and DodoSSH.Client.Android is deliberately outside DodoSSH.slnx, so the solution
restore that keeps the other fourteen lock files honest has never seen this one either. It went stale
for a whole release and nothing could say so.

Two things had drifted by the time the repaired job reached the step: MinVer, added to
Directory.Build.props for the desktop updater, and the ABI set, which grew when the -r android-arm64
pin came off the packaging step so that the nightly is installable on more than an arm64 handset.

--force-evaluate on this project alone, and the fourteen it references come back byte-identical: an
android-* RID is not a graph any of them has a package for. Checked rather than assumed, because this
is the same mechanism that once put win-x64 into the server's lock files and broke its image build.

docs/platform-flags.md records the lasting half, which is not this fix: any change to a shared props
file touches a lock file this repository cannot verify from a machine without the Android workload.
2026-08-04 21:58:02 +02:00
jaap-jan b4a6c19ac1 Let the phone replace itself, and give CI a channel it may sign
ci / build and test (push) Successful in 1m53s
ci / android head (push) Failing after 32s
ci / api image (push) Successful in 28s
The Android head had no updater and no release path, and the two are one problem:
Android refuses an update signed by a different key, and CI generates a fresh debug
key in every container. An APK released from a workflow could be installed once and
never updated again — each new one an uninstall, which on this product means losing
the cache, the outbox and the device key.

So there are two channels, and they are two applications because the platform gives
no third option. dev.dodotech.dodossh is cut from a v* tag by a person running
scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners.
dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore
committed here in the open — a key everybody has cannot be stolen and grants nothing
by being held, which is why putting it in CI does not touch the rule. Neither can
update the other, by construction. See ADR 0014.

The android job assumed an image with a JDK and an Android SDK on it, which is what
a GitHub runner is and what this project's is not. It now installs a JDK, fetches
Google's command-line tools, accepts the licences and installs API 36 — each a no-op
where it is already satisfied, and each cached by the persistent runner's own disk
rather than by an action that would move a quarter of a gigabyte to rebuild a
directory that never left.

The client reads a small JSON manifest beside the APK, the counterpart of
releases.win.json, and compares Android's versionCode rather than a version name:
that integer is what the platform itself uses to accept or refuse an install, so
comparing anything else would offer updates the phone then rejects. It fetches, and
then asks Android to ask — the system draws its own confirmation, and from API 26
will not draw even that until unknown sources is on for this application.

IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files
and restarts, so the shell disposes the vault first and that is what zeroes the keys.
On the phone the install is a request and the answer may be no, so disposing first
would answer "not now" with a locked keychain and every shell closed — a punishment
for declining an update.

Two measured bugs found on the way, both older than this work and both invisible to
a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a
top-level PropertyGroup, so the target setting it from MinVer ran after the only
thing that reads it: every APK ever built here said versionName 1.0.0. And nothing
found so far varies the launcher name per channel — four mechanisms tried, all of
them recorded in platform-flags, none of them reaching the label the launcher shows.
The two channels share an icon name for now and are told apart by package name,
version, and what the preferences screen says.
2026-08-04 21:46:01 +02:00
jaap-jan f90c331334 Merge branch 'claude/android-catch-up'
ci / android head (push) Failing after 6s
ci / api image (push) Successful in 31s
ci / build and test (push) Successful in 1m51s
2026-08-04 19:58:16 +02:00
jaap-jan 50fa6fba38 Let the phone delete a host, and move or remove a group
The desktop gained three things the phone did not follow: moving a group to
another vault, the second question asking whether a group's deletion takes its
hosts with it, and — since long before either — deleting a host at all. What
that left was a head whose v3 + can fill a keychain and whose editors can
correct one, with no way to empty either.

The commands could not simply be bound. DeleteGroup and MoveGroup aim at
GroupTarget, which is the selected card or the open group, and the phone has
neither: its list draws headings, and a heading's selection deliberately bounces
back to the host. Called bare on that head they would have returned having done
nothing — a DELETE that appears to have been pressed and has not. Both now take
the row and fall back to GroupTarget for the desktop's menu, and
ConfirmMoveGroupAsync resolves from the panel's own movingGroupId rather than
from the selection, which is also the honester answer on the desktop: what moves
is the shelf the panel was opened on.

The heading's pencil became a menu. Three icons after a chevron, a name, a vault
badge and a count is what would be left of the name at 360dp, so the ⋯ raises
the add sheet's shape carrying Edit, Move to another vault, a rule, and Delete —
the desktop's card menu, in the one idiom this screen already has. It does not
carry Open: the desktop's grid holds one level of the group tree and this list
holds all of it flattened, so there is nowhere to open a group into.

DELETE under a host sits on a row of its own beneath EDIT and MOVE rather than
beside them. A phone has no hover and no tooltip, so where a thumb lands is the
only thing separating a destructive control from an ordinary one. Both questions
take the controls that asked them — ShowsConnectControls, which is the phone's
half of the rule ShowsHostPaneActions already carries for the desktop's drawer —
so DELETE cannot be pressed a second time underneath its own confirmation.

Preferences gained the running version, and the sentence saying this head does
not replace itself and that no DodoSSH server will ever offer one. It reads
Updates.CurrentVersion off the same view model the desktop's UPDATES section
does, over the null channel that reports itself unsupported.

Nothing was needed for the realtime push: it is composed in ServerConnection,
which both heads use.

Seven tests, all phone-shaped — a group acted on with nothing selected, the menu
waved away leaving nothing armed, the ungrouped heading raising none, and the
bar's three states. The rectangles remain unmeasurable for the reason phase 8
gives; the checks for them are 8.10 to 8.13 and 13.6.
2026-08-04 19:58:10 +02:00
jaap-jan e923b12b7f Merge branch 'claude/desktop-auto-updater-0264a3'
ci / build and test (push) Successful in 1m56s
ci / android head (push) Failing after 6s
ci / api image (push) Successful in 45s
2026-08-04 17:56:50 +02:00
jaap-jan 0b6059d4a8 Stop a late progress report from putting the download bar back
`Progress<T>` does not invoke its callback inline — it posts to the captured
synchronisation context — so a report can be delivered after the download it
belongs to has already returned. The unguarded handler then wrote a stale smaller
number over the 100 set on completion, and nothing reports again, so it stayed
there: a finished download showing 50% next to a banner offering the restart.

Caught by running the suite a second time on another checkout, which is the only
reason it was caught at all. The assertion that failed is the one that says a
found update is fetched without being asked about, and it had passed on every
previous run — the ordering it depends on is real and simply usually goes the
other way.

Guarded rather than made synchronous. The posting is wanted: in the application
these callbacks arrive on whichever thread Velopack downloads on, and setting an
observable property off the UI thread is a binding exception rather than a stale
number. Monotonic is what the bar should have been anyway, since progress that
goes backwards is not progress.

Ran five times over to confirm it is settled rather than merely rearranged.
2026-08-04 17:56:39 +02:00
jaap-jan f37d7ee12e Merge branch 'claude/desktop-auto-updater-0264a3' 2026-08-04 17:53:29 +02:00
jaap-jan 3ead865f01 Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater,
and the two collided in three places. Every one of them resolves the same way:
main got there first, so this branch moves.

**Two ADRs were both numbered 0012.** Main's is realtime push; this one is now
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call
this a conflict — the filenames differ — so it would have merged quietly and left
the directory with two 0012s and every cross-reference ambiguous. Renumbered here
along with the nine places that point at it.

**Two manual-check phases were both numbered 15**, and that one git did catch.
Main's "Changes that arrive without a timer" keeps 15; installing and updating
the desktop client becomes Phase 16, with its checks and every reference to them
renumbered. The file's own rule is that a number is for life, which is exactly
why the one that had not been pushed is the one that gives way.

**The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on
everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and
`EnforceCodeStyleInBuild` is on, so it failed the build on three lines of
App.axaml.cs whose only change in this branch was an ADR number in a comment.
Forty-six files normalised back to LF; the release script keeps CRLF, which is
what `.gitattributes` and `.editorconfig` both already say for a PowerShell file.

Nothing else conflicted. The updater does not touch the sync loop or the event
stream, and the one file both sides edited heavily — MainWindowViewModel — merged
without a hunk in common.

Verified after merging: the solution restores locked and builds clean, and 304
shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The
first two counts are higher than before the merge because main's own tests came
with it and pass alongside these.
2026-08-04 17:52:57 +02:00
jaap-jan 5a8731ca8c Merge branch 'claude/group-vault-migration-deletion-4af5f4' 2026-08-04 17:11:33 +02:00
jaap-jan 780f4bf892 Merge branch 'main' into the group's move and its deletion question
Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
2026-08-04 17:11:06 +02:00
jaap-jan 6728a0a597 Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
2026-08-04 17:04:41 +02:00
jaap-jan a86731ee08 Move the shelf as well as what is on it, and ask what a deletion takes
Two things about a group, and they turn out to be the same argument twice.

**A group can be moved to another vault, and it takes everything on it.** The
host's move shipped last week and stopped one level too low: moving twenty
machines into a shared vault meant twenty trips through a menu, and each one
arrived stripped of the group it had been filed under, so the shelf had to be
rebuilt by hand on the other side. Moving the shelf is what people were
attempting. MOVE sits between EDIT and DELETE over the group cards, and on the
card's own menu above the separator DELETE is below — the same place, and the
same reasoning, as the host pane's ⋯ entry.

**The whole subtree goes, and taking less was never coherent.** A group's
children are items of the vault it is leaving, so a parent moved alone leaves
them naming a tombstone and they surface as roots in the vault the user has just
emptied: half a shelf here and half there, from one gesture that said "move
this". The hosts are the same argument and are the half the request was about.

The groups go first, top down, and the hosts last. Each item is re-sealed under
the destination's key and takes a new id — VaultItemRepository.MoveAsync, which
HostGroupRepository now exposes — so nothing pointing at a group can be written
until that group has landed and its new id is known, and a child's parent must
already be over there. What an interruption leaves is therefore hosts still in
the vault they started in, under UNGROUPED: visible, and re-movable. The reverse
order would leave hosts in the destination filed under nothing.

The parent stays behind and the tags are dropped, which is the host move's rule
one level up: both are items of the vault being left, so a reference carried
across would resolve on the machine that moved it and dangle for everybody else
in the destination. The moved group arrives at the top level, and the panel says
so before the press rather than the status line saying it after. Keys and
passwords are kept — those genuinely resolve across vaults, and clearing them
would take a working host and make one that cannot connect — and any now outside
the destination is named, because that is precisely what the other members of it
will not be able to resolve.

Refused as a whole where anything under the group was written by a newer client,
rather than skipped item by item: a move that left behind what it could not
re-encode would file some of the shelf in one vault and the rest in the other,
which is the state this exists to prevent. Refused with a host editor open, as
the drop gesture is, because it rewrites hosts. And the walk carries a visited
set, for the reason every walk over this tree does: a group that is its own
parent — which two offline clients can build and no editor was ever shown —
would otherwise be appended to the move list for as long as there was memory.

**Deleting a group now asks what should become of the hosts under it, and that
reverses a decision this repository had written down.** The deletion did not
touch them: the reference was left dangling, the list resolved it to nothing,
and the machines turned up under UNGROUPED. That was right for one of the two
things people delete a group for and wrong for the other — a heading being tidied
away should leave its machines alone, and a project that has been decommissioned
is a shelf and everything on it — and nothing in the code can tell which of the
two it is looking at. So it is asked.

A tick rather than a pair of options, because the two answers are not equally
weighted: keeping the hosts is recoverable and deleting them is not, so the safe
answer is the one that needs no decision. It is off on every question, including
the one that disarms it, or a tick left standing would destroy the next group's
machines on the strength of a decision about the last one's.

Once the deletion knows which hosts it means, leaving them naming something that
has gone is a state kept for no reason, so the unticked answer writes too: N
hosts with the reference cleared, where the ticked one writes N tombstones. That
is the N writes HostGroupRepository refuses to hide behind a DeleteAsync
overload, made where somebody asked for them and where the count is on screen
first. The nested groups take the deleted group's place in the tree rather than
being orphaned to the top level. A read-only host is skipped, counted and named,
because unfiling it would re-encode a payload this build cannot represent — and
the cost of skipping is a dangling id, which every reader here already survives.

**Both are the desktop's alone**, and that is not an omission. The phone draws
groups as headings in the host list and has never had a way to delete or move
one; the two panels take the row of buttons over the group cards, and there is
no such row on a 360dp screen to take.

One test had to change its premise rather than its assertion.
EditingAHostWhoseGroupIsGone built its dangling reference by deleting the group,
which now unfiles instead — so it imports a host naming an id nothing resolves,
which is what a group deleted on another machine actually looks like and is the
only way that state still arises. The picker's placeholder is still needed and
still covered.

Four places said an item could not be moved between vaults. Two were about a
group and were true when written; the other two were left stale by the host's
move. All four now say what is true, including the design gaps document, where
the chevron beside the vault name stays undrawn for the reason it already had.
2026-08-04 17:04:06 +02:00
jaap-jan 6fad84c484 Merge branch 'claude/remove-group-edit-delete-buttons-3d38ec'
ci / build and test (push) Successful in 1m31s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 27s
2026-08-04 16:40:56 +02:00
jaap-jan d02d103569 Take the group's EDIT and DELETE off the heading row
A pair of buttons sat at the end of the GROUPS heading, and the card's own
right-click menu arrived later offering the same two things. Two controls for one
job, and the buttons were the harder of the two to read: a button beside a
heading has no card under a pointer to mean, so it had to work its subject out —
the selected card, or failing that the group the trail ends with, which once a
group is open is not a card on screen at all. The menu never has that problem,
because opening it is what aims it.

The menu is the whole of Edit and Delete on the desktop now. ShowsGroupActions
went with the buttons, since hiding them was all it did. GroupTarget stays: the
menu's two entries read it after the code-behind has selected whatever was
right-clicked, and its fallback to the open group is what makes + NEW HOST open
on the group somebody is standing in rather than on none.

One case changes shape. Opening a group with nothing inside it folds the card
grid away, so from in there nothing can be right-clicked — renaming that group
means pressing the trail back one level, to where it has a card of its own. The
buttons used to cover it through the fallback. The trail was already the way out
of an empty group, and 3.2a says so now.

The test that pressed EDIT through its binding is replaced by one holding that
no button on the screen commands either of them, which is the failure worth
catching: a button coming back is not a compile error, and it would draw itself
in place, aimed at the group the trail ends with. What that test covered — a
command that has to accept an empty parameter, and act on the card the pointer
was on — the two menu tests beside it already do.
2026-08-04 16:39:14 +02:00
jaap-jan 589300253d Merge branch 'claude/vault-realtime-push-d64c61'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Canceled after 36s
2026-08-04 16:38:42 +02:00
jaap-jan 4b706bc3c3 Say when a vault has moved, so nobody waits out the minute
The delta pull was cheap enough to run on a timer and the client did, once a
minute. That is fine for a machine and wrong for two people: an edit a colleague
makes is up to a minute stale, which is long enough for both of them to make it
and produce a conflict neither needed to have. Shortening the interval is the
obvious answer and the wrong one — it costs a request per client per interval
whether or not anything happened, and it converges on a busier server that is
still late.

So the server now says so. A client holds a WebSocket open at GET /api/v1/events,
subprotocol dodossh.events.v1, and gets a line down it when something it can read
has changed. ADR 0012 has the reasoning; three parts of it are worth repeating
here, because they are what everything else rests on.

**What crosses the socket is a notice, never data.** A frame names a vault and
how far its change log has got. No item, no ciphertext, not even which item it
was. The client's answer is the delta pull it would have run anyway, so there is
still exactly one code path that applies a change to a keychain, and it is not
this one. Pushing the items themselves would save a round trip and fork that path
in two, with the cursor, the merge and the tombstone rules duplicated across both
— ADR 0003 put every mutation through one write path for that reason, and this
keeps every read on one for the same one. It also makes a dropped notice
harmless, which is what lets the fan-out below be as simple as it is.

**Polling stays, and is what guarantees a pass.** The minute timer is unchanged.
A network that eats WebSockets, a server with Events:Enabled off, an older
server, a proxy that will not upgrade, a notice dropped under backpressure —
every one of those leaves a client behaving exactly as it did before this commit.
Nothing is reachable only over the socket and nothing is meant to become so;
VaultViewModel's AutoSyncInterval remark now says that where somebody changing it
will read it.

**The bearer token authorises the upgrade, unlike the relay's ticket.** Not an
inconsistency with ADR 0004: the relay's socket is a byte pipe whose whole
authorization decision — which host, which IPs, which port — is made before it
opens and never revisited, and it is the extraction seam for a process that must
hold no ACL code. This one is a view of the caller's own vault list and has to
keep answering "what may this account read" for as long as it is held. A ticket
would carry that answer in a token and be wrong the moment the account's access
changed. The two bounds that arrangement needs are met rather than waved at: the
socket is closed at the token's exp with close code 4401 and the client comes
straight back with a fresh one, and the vault set is re-resolved every few
minutes as well as on the changes known to affect it. Both bound *metadata*,
because a notice contains nothing else and reading a vault still needs a key this
server has never held.

**The fan-out.** VaultEventHub is a singleton holding the sockets this node
accepted; publishing walks them and asks each whether it cares, rather than
keeping a vault-to-subscriber index that every re-subscription would have to move
entries between under a lock publishing also takes. At a few hundred sockets per
node and an event rate bounded by how often people edit keychains, the walk is
not measurable and its races are obvious. Per-connection queues are bounded and
drop the *oldest*: a notice means "pull vault X, which is at least at sequence
N", so the newest subsumes what it displaces and the client's answer is identical
either way — which is what lets the publish path be void, never block, and never
fail.

Announced from the endpoint rather than from SyncService, and that placement is
the point: by then the push has committed and released the per-vault advisory
lock. From inside it would name a sequence no reader can see yet and would hold
the lock that serialises writers across a socket write. Only the highest
*applied* sequence, so a batch of pure conflicts announces nothing, and a
duplicate — already announced when it first landed — announces nothing either.

Grants and membership publish too, and those take the *recipient* rather than the
actor. This is what AdmitNewVaultsAsync has been apologising for since sharing
shipped — "the recipient is handed nothing, there is no push channel" — and the
README with it. A vault shared with somebody now turns up as it is shared. The
comment and the README paragraph both say what is true now, and both keep saying
that the pass is what *discovers* the vault, because a client with no socket has
to arrive at the same place.

**On the client**, VaultEventStream is really a reconnection policy wrapped round
a ClientWebSocket: a dropped socket is the ordinary case here — laptops sleep,
proxies time out, tokens expire, servers are redeployed — so nothing in it treats
a failure as exceptional, and every path ends in "wait, then dial again". A
connection that lived long enough to say hello resets the backoff, so a laptop
that woke, worked, and lost its network an hour later does not inherit a
minute-long wait it has already proved it need not take. A 4401 close skips the
backoff entirely and asks the token provider again, which is the whole reason
that close code is distinct. A server that does not advertise the events feature
gets IdleVaultEventStream, which never delivers — so IVaultServer.Events is never
null and every caller stays on one shape, because the correct behaviour without a
socket is the behaviour with a silent one.

The shell's background loop now selects between the timer and a notice, and both
waits are held across iterations. That is load-bearing rather than tidy:
PeriodicTimer permits one outstanding WaitForNextTickAsync and throws on a
second, and an abandoned channel read stays registered and consumes the next
notice written. Either defect leaves the first notice working and every one after
it silently lost, which is why NoticesKeepWakingTheLoop_NotJustTheFirst pushes
three and not one. Notices are coalesced over a quarter of a second, so one
person's save — a host and its log entry are two items — and a colleague clearing
a folder each cost one pass rather than a dozen.

**The kind is a string, not an enum**, and that is a compatibility decision.
UseStringEnumConverter throws on a value it does not know, so a newer server
sending a kind an older client had never heard of would not add an unreadable
frame — it would break that client's socket outright. A string is ignored
instead. ProblemCodes is the same shape for the same reason.

**Tested on both sides, through the real pipeline.** The endpoint suite opens a
genuine socket against TestServer and proves a push produces a notice, that
another account's push does not reach it, that a ping is answered, and that a
frame this server cannot parse does not end the connection. Two of those assert
on *ordering* rather than on absence within a timeout — the stranger's write goes
first, so a socket that leaked would have announced it before the one the test
waits for — because "nothing arrived in two seconds" is a test that passes on a
slow machine for the wrong reason. And ANoticeCarriesNoCiphertext asserts on the
bytes that crossed the wire rather than on the record's fields, since the latter
would only prove that this type has no payload member, which is a tautology; the
former is what catches a field added later without anybody thinking about
disclosure.

The client suite drives VaultEventStream through an injected connector, because
the one thing a test cannot do to a real network is make it fail on cue — and
failure is the entire subject. The shell suite proves a notice produces a pull
inside ten seconds against a sixty-second timer, so the timer cannot be what
caused it.

**Two limits, stated rather than left to be discovered.** Fan-out is in-process,
so a deployment running more than one API replica only pushes for writes its own
replica handled and the rest arrive on the timer. IVaultEventPublisher is the
seam a PostgreSQL LISTEN/NOTIFY backplane implements and it is deliberately not
implemented: an untested backplane is worse than a documented gap, and multiple
replicas degrade to the behaviour before this commit rather than breaking. And a
client is notified of its own writes; it pushed, so it already pulled, and the
extra pass finds nothing. Suppressing that echo correctly needs a per-device
identity on the socket, and the same user's other machines must still be told.

Manual checks phase 15 covers what no test here can reach, which is the network
in between: a proxy that will not upgrade, one that drops an idle socket without
telling either end, a laptop lid, a token expiring. Every one of those is
invisible inside a test host, and every check there passes only if the change
arrives quickly *and* still arrives with the socket taken away.

ADR 0012 also fixes one thing about the shared terminal session this is the
transport for, so it need not be renegotiated later: session data will be binary
frames on this same socket, because base64 in a JSON envelope is the wrong shape
for the one payload here that is continuous rather than occasional. Two questions
it explicitly does not answer by implication — whether those bytes go through the
API at all, and what end-to-end encryption means when the second party watches a
stream rather than holding a key — are ADR 0001 questions and get their own
decision.

1512 tests pass. DodoSSH.SystemTests was not run — it needs the whole compose
stack — so the end-to-end path is unverified for this change beyond what the
manual checks describe.
2026-08-04 16:37:41 +02:00
jaap-jan 742f65c204 Merge branch 'claude/card-selection-state-sharing-f9348c'
ci / build and test (push) Successful in 1m42s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 27s
2026-08-04 16:28:34 +02:00
jaap-jan 0258ec3029 Merge branch 'claude/groups-vault-sharing-e4b154'
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 2s
# Conflicts:
#	src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
2026-08-04 16:28:29 +02:00
jaap-jan 3f419cb19b Let one card be selected at a time
The hosts screen draws two grids, one above the other, and each is a ListBox
with a selection of its own. Nothing joined them, so a group card and a host card
could be lit at the same moment — two chosen things, under two pairs of buttons
of which only one would act on whichever the eye had settled on. Both grids mark
a selection the same way, so there was nothing on screen to say which of the two
the next press belonged to.

They share one mark now. Selecting a host clears the group and selecting a group
clears the host, and that second one takes the detail pane down with it: a pane
about one machine cannot go on standing beside a marked group, because nothing
on it would be about what is selected.

Losing a selection deliberately clears nothing. A null arrives whenever either
list is rebuilt — every keystroke in the filter box and every background sync —
and treating that as somebody deselecting would take the mark off a group card
because a search emptied the grid beneath it.

The reload needed the same guard for the same reason. It falls back to the first
host when nothing is selected, which is what puts a target under CONNECT on a
fresh unlock; with one mark between the two grids that fallback would have
unselected a group nobody had touched, once a minute. It is skipped while a group
holds the selection, and it moved into a method of its own because the comment
saying why pushed ReloadHostsAsync past sixty lines.

One consequence needed handling rather than accepting. The phone's only route
into the group editor is a button on a heading in the host list, and it worked by
selecting the group first — which under this rule takes the highlight off the
machine somebody was about to connect to, on a screen that draws no group cards
to say where it has gone. EditGroup takes the group as an argument now: the
heading passes its own row, and the desktop's button beside the cards passes
nothing and still means "the card that is selected".

What the tests hold is the half that lives in the controls. Clearing the property
has to reach the list that is drawing the card, and a selection nulled in the
view model while the card stays highlighted is the exact failure this is about —
so the rule is driven on the real screen, in both directions, against the
ListBoxes' own SelectedItem. The desktop's EDIT is pressed through its binding
for the same kind of reason: a command refusing the button's empty parameter
would be a button that never fires, and nothing about the markup would say so.
2026-08-04 16:27:20 +02:00
jaap-jan c39df3f51e Share the shelf as well as what is on it, and ask a group which vault
A group is where hosts are filed and what lends them a port, a username and a key,
and until now it could only ever be made in the vault this machine files new items
into. So sharing a vault shared the machines and not the arrangement: a colleague
opened four hosts filed under a group they could read the name of and nothing else,
and the group a teammate made had no card, no heading and no way to be corrected
from the screen looking straight at the hosts inside it. Recorded as half shipped in
docs/design-import-gaps.md, and this is the other half.

The list stopped being the active vault's. It was narrow for two stated reasons —
a row shown across vaults has to carry which vault it lives in, because rename and
delete both need it, and two vaults may hold a "production" each, which a layout
with one heading per group cannot tell apart — and both are now paid for rather than
avoided. Every row carries its vault, the badge beside the name says which, and the
two cards sit side by side saying what they are.

The three shapes of the group read are now deliberately different sizes. The list is
what a person looks at, so a hidden vault's groups leave it: a card that cannot be
opened onto anything is worse than no card. The per-vault lists are what a picker
offers, because a picker is always asking about one vault. The map is what a host's
GroupId resolves through, and it stays widest of all — including over hidden vaults,
since a group lends a port and hiding a vault must never change what one of its
hosts dials. RebuildGroups is the one place hiding is applied, which is what keeps
those answers apart.

The editor asks which vault on the terms the host editor's picker set: while adding
only, hidden where there is one writable vault, and never offered afterwards, because
the two are encrypted under different keys and moving an item is a delete and a
retype. Its parent picker is that vault's alone, for the reason the host editor's
group picker is one level down — a parent in another vault is a level half the key
holders cannot resolve, and their hosts would inherit from nothing. + NEW GROUP
inside an open group departs from NewHost and takes that group's vault rather than
the standing preference: a group made inside another is in its parent's vault by
construction, and answering "inside PLATFORM" with a group elsewhere and no parent
would drop the one thing the button said.

Two smaller things follow from the cards spanning vaults. Dragging a host onto a
group card in another vault is refused with both names, because the write it would
make is exactly the id-nobody-can-resolve the host editor's picker was fixed to
prevent, and treating it as "no group" would unfile a host somebody was plainly
filing. And a group being renamed says its vault in the drawer's header, since the
picker is not drawn for an existing one and renaming a colleague's shelf without
being told whose it is is the edit most worth naming.

The save target is a nullable field behind a property that falls back to the standing
preference. The group name box is bound whether or not anything raised an editor over
it — that is what the desktop's group bar was, and typing a name into it and pressing
ADD is still a way to make a group, which would otherwise have written to no vault
at all.

1575 tests pass, five more than before: a group filed into a shared vault is listed
and renamed there, the editor's picker does not move the keychain screen's, the
parent picker offers only its own vault, a cross-vault drop is refused, and hiding a
vault takes the cards without changing what its hosts dial.
2026-08-04 16:27:16 +02:00
jaap-jan be012585b3 Let a group be right-clicked, as a host already can
The host cards have had a menu since the grid replaced the sidebar; the group
cards above them had a double-click and two buttons beside the heading, and
nothing that named the card under the pointer. Open, Edit and Delete are on them
now, drawn and aimed the same way.

On the list rather than in the item template, for the reason the host grid's is:
the three commands are the vault's, and a ContextMenu inside a DataTemplate has
the row for its data context, so every binding in it would silently resolve to
nothing. The code-behind selects whatever was right-clicked before the menu
opens, and that is what makes one menu act on the card under the pointer rather
than on whichever was selected before.

**Cancelled over the space around the cards, and here that guard is doing more
than the host grid's.** GroupTarget falls back to the group whose contents are on
screen when no card is selected — the right answer for a pair of buttons beside
the heading, which would otherwise have no subject the moment a group with
nothing inside it is opened, and the wrong one for a menu that opened on a card.
Without the guard, right-clicking the gap beside the cards would offer to delete
the group the trail ends with: a question about something the user is not
pointing at, in the one menu where the answer is a deletion.

Only Open takes a parameter, and it has to. OpenGroupCommand's null is a real
argument rather than a missing one — it is the trail's first crumb, ALL HOSTS —
so an entry with no parameter would not open the card, it would leave the group
the user right-clicked and go back to the top level.

Two tests beside the two the host menu already had. What they hold that a build
cannot is the CommandParameter binding: a path that resolves to nothing compiles
and draws, and the entry would then quietly do the opposite of what it says. The
popup itself is still the platform's, so manual-checks 7.9 gained the group half
of the same check.
2026-08-04 16:27:01 +02:00
jaap-jan 7f5b871c47 Merge remote-tracking branch 'origin/main' into claude/vault-share-s3-delete-a84c35
ci / build and test (push) Successful in 1m40s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 26s
2026-08-04 16:04:25 +02:00
jaap-jan bee6202949 Let a host be moved to another vault
The one thing the host editor's vault picker has always been unable to offer,
and the comment beside it said so: an existing host's vault was not a field
because the two vaults are encrypted under different keys. That is still true.
What changed is that it is no longer a reason to have nothing.

**A move is a copy and a tombstone, and it cannot be anything else.** A payload
is sealed under its vault's key and its AAD binds the vault, the entity id and
the item version, so no edit moves one and no server call could — the server
holds ciphertext it cannot read. What crosses is the plaintext, in this process,
between an unwrap under one key and a seal under another. VaultItemRepository
gained MoveAsync for it, so the three decisions below live in one place with
their reasons rather than being re-derived at each call site.

The item takes a new id. Keeping it would put one entity id in two vaults, and
the item table is keyed on the type and the id rather than on the vault — so the
destination's row and the source's tombstone would be the same row, and the move
would delete what it had just written.

The write comes first and the tombstone second, which decides what an
interruption leaves: a copy in both vaults, visible and deletable, rather than a
tombstone with nothing on the other side. Both are queued rather than sent, so
the window is a crash between two local writes; it is still worth being on the
survivable side of.

Two activity lines rather than one, because that is what the two vaults actually
record. A single "moved" line would have to be written to one of them and would
be missing from the other's history.

**The group and the tags stay behind, and that is the half that makes this
honest.** Both are items of the vault the host is leaving: the editor's group
picker offers one vault's groups and the chips are drawn from one vault's tags.
A host carrying either across would resolve it on the machine that moved it —
groups and tags are resolved over every readable vault — and dangle for everybody
else in the destination. The mover and their colleagues would be looking at two
different hosts. Cleared and reported beats carried and invisible.

The key or password binding is kept, and the difference is not inconsistency.
Those genuinely resolve across vaults — one key on twenty hosts in three vaults
is the arrangement they exist for — so clearing them would take a working host
and make one that cannot connect. What the message does instead is name a
binding that is now outside the destination, because that is precisely what the
other members of it will not be able to resolve.

**It is not in the editor**, on either head: the desktop puts it in the detail
pane's ⋯ menu above the separator Delete sits below, and the phone beside EDIT.
A picker inside the form would move a machine as a side effect of correcting a
port, which is the bug the editor's own vault picker was fenced off to prevent in
the first place. The panel takes the footer as the deletion question does, and
says what will be left behind before the tap rather than after it — on a phone,
where the status line afterwards is one line on a screen somebody has already
navigated away from, that is the only place it reliably gets read.

The phone hides the button where there is nowhere to go rather than offering one
that answers with a refusal; the desktop keeps its menu entry either way, because
a menu that grew and shrank would be a menu whose items move.

One thing found while writing the test and deliberately not changed. The pass
that follows every write on this screen reports what it moved and supersedes the
confirmation — for a save and a delete as much as for a move — so the move's own
sentence is what somebody sees offline. The test asserts it in that state and
says why. Making confirmations survive their own sync pass is a question about
the whole screen rather than about this.

Four places said an item could never be moved, two of them sentences on screen in
both heads. All four now say what is true, including the design gaps document,
where the chevron beside the vault name stays undrawn for a different reason: a
chevron on a subtitle implies an edit, and this is a re-seal, a new id and two
references left behind.
2026-08-04 16:04:15 +02:00
jaap-jan 176df67861 Merge branch 'claude/vault-share-s3-delete-a84c35'
ci / build and test (push) Successful in 2m4s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 33s
2026-08-04 15:36:36 +02:00
jaap-jan e9cea2ccbc Let a shared vault arrive, a bucket be found, and a vault be deleted
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.

**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.

So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.

The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.

**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.

The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.

The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.

**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.

The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.

Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.

The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.

Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.

Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
2026-08-04 15:34:40 +02:00
jaap-jan 6f960e5674 Merge branch 'claude/main-page-group-hierarchy-3a3210'
ci / build and test (push) Successful in 1m37s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 33s
2026-08-04 14:31:49 +02:00
jaap-jan a0568d4c35 Merge branch 'main' into the vaults screen, and let it rotate keys too
Main built vault key rotation while this branch was reshaping the screen that
would drive it, so the two met in the same three files. Every other conflict was
textual and resolved by taking both; these are the ones where a decision had to
be made.

**The view model.** Main taught TeamsViewModel three things and this branch had
renamed and rewritten it into VaultsViewModel. All three are ported rather than
dropped, because each is a behaviour rather than wording: adding somebody now
wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed,
removing somebody rotates the vault and hands the new key to whoever is left, and
a share reports how many generations were wrapped. The session calls they reach —
ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list
rather than to one vault, and they are called that way here rather than narrowed:
adding somebody is a change to the list, so every vault the list carries is one
they can now fetch. This screen makes lists that carry one vault, so the sentences
name one; where a list carries several, naming them all is the honest report, and
the members section already says the list is shared.

AddMemberAsync ran two lines over the length limit once the sharing was in it, so
the calls behind it moved to AddOrInviteAsync and the three-way refusal to
WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was
before the sharing arrived.

**The tests.** Main's four new cases are ported to the vault-first API, including
the one that matters most: the tampered key log is corrupted *before* the add,
because the add is now a route to a wrap and a test that corrupted it afterwards
would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey
now expects two holders rather than one — main's fake records the creator's own
self-grant, and a key-holder list that omitted it would show the one person who
can certainly open a new vault as somebody who cannot.

**The README.** The limits list is six rather than four or five: main's rotation
entries and this branch's "a vault cannot be deleted" describe different things
and both are true. "The rekey is flagged, never performed" is gone, since it is
now performed, and M3 reads *Done* rather than *Done, except rekey*.

One thing worth writing down that neither side had. An invitation claimed at
sign-in still leaves the key owed, where an add does not: at the moment an
invitation is issued there is no account and no published key to wrap to, and the
claim happens on the invitee's machine, which holds nothing. Manual check 12.1
says so, because a reader who knows adding shares would otherwise read that step
as stale.

1561 tests pass.
2026-08-04 13:58:56 +02:00
jaap-jan 8707629a6c Make the vault the thing you share, and ask a host which one it lives in
The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.

Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.

The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.

Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — it was only ever possible for a team owning no vaults, and a
screen whose rows are vaults has no row for one, so the button would have been
unreachable or always refused. The endpoint is unchanged and the screen states
the limit instead, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.

What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on PermissionFlags.Admin — the line
UpdateTeamEndpoint already draws, because a name is what everybody in the vault
sees it called rather than part of its contents — and it renames the owning team
with it when that team carries nothing else, so the row an operator reads and the
name a user says cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.

The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.

That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.

Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.

1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
2026-08-04 12:22:29 +02:00
jaap-jan 8d2f4c8ffb Merge branch 'claude/main-page-group-hierarchy-3a3210'
ci / build and test (push) Successful in 1m27s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 21s
2026-08-04 10:26:20 +02:00
jaap-jan 6ae1912c34 Give the two logs and the buckets a resource type, so a conflict can be written
AadResourceTypes.For maps a syncable type onto the AAD resource type its cache
records bind to, and it had no arm for ConnectionLogEntry, ActivityLogEntry or
ObjectStore. All three are on both enums, in the reconciler registry and in the
cipher pinning; only this switch was missed, and it throws rather than falling
back — so a merge conflict on a connection log, an activity log or a bucket
raised ArgumentOutOfRangeException on the path that records what the merge
discarded. The conflict log is the whole reason the merge is allowed to pick a
winner, so the one item kind whose conflicts could not be recorded was a bucket:
an editable item two machines can genuinely disagree about.

Worth writing down why it lasted two phases. Of the three callers, ItemStore and
OutboxStore reach the mapping only when an item carries plaintext fields, and
none of these three kinds does — so they never touched the gap. ConflictStore
calls it unconditionally, but a test only reaches that by causing a real merge
conflict, and every existing one raised its conflict against a Host. Three arms
missing, and no path in the suite crossed any of them.

So the tests are the point of this commit as much as the arms are. The guard is
AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping: it walks the
whole wire enum, and for each type asserts both that there is an arm and that the
arm returns the same-named resource type, which is the mistake the file's cipher
half already guards against on the server side. Written over the full enum rather
than over ItemKinds.SyncedTypes, because that is the stronger claim and the one
the switch really makes — the two reserved association types have arms too.
Beside it, CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem records a
conflict per kind and reads the detail back, since an arm returning the wrong
resource type seals under one AAD and opens under another, which surfaces as an
empty detail rather than as a throw.

Both were confirmed to fail with the arms removed: the theory fails on exactly
ConnectionLogEntry, ActivityLogEntry and ObjectStore and passes on the other
three, and the guard names those three and no others.

The note in docs/adding-hosts-on-the-phone.md that recorded this as out of scope
is marked fixed, with what let it survive, since that is the part worth knowing
next time an item kind is added.

1529 tests pass, seven of them new.
2026-08-04 10:24:47 +02:00
jaap-jan 805d81dbcc Merge branch 'claude/team-key-share-rotate-4b2619'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 21s
Two conflicts, and the second is worth recording. main's M4 bullet gained the
Android signing decision while this branch rewrote the M5 line either side of it;
both are kept.

The other is an ADR number collision: two sessions each took 0010, one for vault
key rotation and one for Android distribution, and both are now on main. ADR
numbers are identifiers — "see ADR 0010" appears in code comments as well as in
prose — so leaving two would make every such reference ambiguous. The rotation
ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009
and the sync code; the Android one is referenced from README and android-port.md.
So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011,
with its title and both references updated. Nothing about either decision changes.
2026-08-04 10:23:46 +02:00
jaap-jan 5d447da532 Take a rotated vault's contents onto the new key as well
Rotating a vault re-keyed the vault and not its contents, which was the deal
struck last time: everything already stored stayed sealed under the generation it
was written with, every remaining member kept the older keys, and the guarantee
was narrowed to "nothing written from now on". That left one gap worth closing —
somebody who walked off with the old key could still open old ciphertext they
later got hold of — and the reason it was safe to defer is the reason it was
cheap to add. A vault at mixed generations reads perfectly well, so the pass that
moves items across can stop half way and be run again.

VaultResealer walks the vault and rewrites each item as an ordinary upsert
against the version the server holds. It never decodes the plaintext: an item is
opened and the same bytes are sealed again under a fresh data key, so an item
written by a newer client crosses a rotation untouched rather than being
re-encoded through this build's codec and quietly losing the fields this build
has no concept of. It also means nothing in the pass knows what an item is, which
is why one loop covers every type including the ones added after it. A conflict
is counted and skipped rather than merged — there is nothing to merge, since no
content changes — and the next pass picks the item up at the version the other
client left.

The half that a pass over stored items cannot see is a change queued before the
rotation and pushed after it, which would put a brand-new item into the vault
under the key the person who just left still holds. So the push path re-seals a
stale payload as it dispatches it, writing the revision back to the outbox first
so that a retry sends the same bytes rather than a fresh envelope. Between the
two, nothing reaches the server under a superseded generation at all. Queued
items are therefore deliberately left alone by the pass: rewriting one there
would overwrite the user's unpushed work with the version the server holds, which
is the one thing a re-keying pass must never do.

Removal runs it last, after a sync — a mirror that is behind produces a batch of
conflicts instead of a re-sealed vault — and the status line distinguishes the two
guarantees, because they are not the same: a vault fully re-sealed is closed to
the person who left, and one with items outstanding is closed only to what
happens next.

Six tests, and three mutations run against them: making the re-seal return the
payload unchanged fails five of the six, making the push path skip re-sealing
fails the queued-edit test and only that one, and counting conflicts as applied
fails the write-elsewhere test. One of the six was wrong before it was right — it
modelled a third-party write by re-pushing an existing payload at a bumped
version, which no real client would do, and it took reading the AAD to see that
the test was lying rather than the code.
2026-08-04 10:18:14 +02:00
jaap-jan 27bb1deb5d Say what the docs describe, in the order they describe it
A tidy across docs/, and every change is either something that had drifted
from the code or something a reader would trip over. Nothing here is a rewrite:
the audit documents keep their audit-time text, because that is what the scope
was decided against, and corrections are marked in place the way each file
already marks them.

The one that matters is crypto.md. Section 4.3's resourceType table stopped at
13 while CryptoSpec.AadResourceType has carried ConnectionLogEntry = 14,
ActivityLogEntry = 15 and ObjectStore = 16 since the logs and the buckets
shipped — and items have been sealed under all three. That file is normative, so
a table three item kinds behind the code is the sort of gap somebody
reimplementing this would build against. Nothing on the wire or on disk changes;
the list now says what the code has been doing, and it notes the thing that
makes it worth reading carefully — 14, 15 and 16 are the first members that are
not a fixed offset from their SyncEntityType counterparts, because 12 and 13
closed a hole and pushed the two enums apart.

manual-checks.md had Phase 3 in an order nobody could follow: 3.2b, then 3.2a,
then 3.4a, then 3.3. The letters are not the problem — they exist so a check can
keep its number for life, which is what lets HostGridTests cite 7.6 and
platform-flags.md cite 3.6-3.8 — so the sections are reordered and the numbers
are untouched, and the intro now says the convention out loud rather than
leaving the next person to infer it from two orphaned letters. Phases 8 and 9
got the horizontal rule every other phase has. The Phase 3 preamble still said
"a sidebar that now draws headings", which has been two different interfaces for
a while: the desktop's grid of cards and the phone's flat list.

7.6 cited MovingAHostToAGroup_FilesItAndLeavesItSelected, which was renamed in
the commit before this one. Every other Type.Member citation across all six
files was checked against src/ and tests/ the same way; those two were the only
ones pointing at nothing.

design-import-gaps.md's v3 section described the grid of cards without saying it
holds one level, and its Groups row still said the sidebar emits one heading per
group — true of the phone and not of the desktop. The team-groups row said a
host a teammate filed "shows under UNGROUPED", which is now only half the story
and the interesting half is why: the chip resolves through groupsById, which is
every readable vault, while the level resolves through Groups, which is the
active vault alone — so the card sits at the outermost level with its group's
name still on it. Verified against both call sites rather than reasoned about.

android-port.md's destination inventory is the desktop as it stood when the
audit was taken and is deliberately left that way, with a note at its head
saying so and two inline corrections where a reader would otherwise be misled:
the rail lost FILES and S3 to fixed tabs, and the hosts sidebar became a grid —
which is exactly why the headings this head draws are the phone's alone now.

adding-hosts-on-the-phone.md is a plan marked built, so its two open questions
are answered in place: GroupId did get into the merge test's with block, and the
server test that asserts a plaintext ParentId is refused survived under a name
that says what it means. Its "found on the way" note is marked still open,
because it is: LocalCacheProtector.For has no arm for ConnectionLogEntry,
ActivityLogEntry or ObjectStore, so ConflictStore.Record still throws for all
three. That outlived the phases that shipped them, which is the drift a note
like that exists to prevent.

No code changed, so the suite is untouched at 1522 passing.
2026-08-04 10:15:02 +02:00
jaap-jan cba6f435e9 Merge branch 'claude/vault-creation-sharing-62c0b6'
ci / build and test (push) Successful in 1m38s
ci / android head (push) Failing after 9s
ci / api image (push) Successful in 28s
# Conflicts:
#	README.md
2026-08-04 10:10:40 +02:00
jaap-jan 89f1f07992 Merge branch 'claude/main-page-group-hierarchy-3a3210' 2026-08-04 10:10:19 +02:00
jaap-jan ebb88c8ae4 Give the phone both pickers, and settle who signs the APK
The files screen could browse a remote and delete on it, and that was all: there
is no browsable local filesystem on Android for a second pane to show, so the
gesture the desktop is built around — choose on the left, press the arrow — has
nothing to stand on. What replaces it is the platform's own two pickers. ADD
FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and
goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the
selected row.

Both stage through the application's cache, and that copy is a requirement
rather than a shortcut. android-port.md predicted a picked document would be a
third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks,
because an upload resumes from the byte the last attempt reached, and a
content:// URI has no path behind it, no length worth trusting, no promised seek
and no grant that survives the document being edited underneath it. Copying
first costs one class in the head and nothing at all in the shared layers, where
the alternative was every resume rule rewritten around a stream that cannot
rewind. The copy is deleted when the transfer completes, kept while it is stopped
so RESUME still has something to read, and swept at the next launch — which is
the one moment emptying that directory is provably safe, since nothing has
queued anything yet.

Coming out had a decision going in did not: when to ask where it goes. The save
picker is raised before the transfer, so the download runs into the same staging
directory and hands its bytes to a callback the head supplied, held against the
transfer id so a RETRY still lands where the person pointed. Asking afterwards
would put the picker minutes from the button that caused it and, on a phone,
usually while the application is backgrounded and Android will not show one at
all. The cost is that the picker creates its file when it is dismissed, so a
download that then fails leaves an empty one there; that is said on the screen,
in the README and in the manual checks rather than left to be discovered. A
delivery that fails keeps the staged bytes for the sweep instead of throwing away
the one copy of something just fetched over somebody's network.

The foreground service counts transfers now, which is the half of it that
matters most here: a shell survives backgrounding because somebody is looking at
it, and an upload has to survive precisely when nobody is. Queued counts as
active, so putting five files in and locking the phone moves five files. The
seam was built for this and wired to () => 0 because nothing could fill the
queue.

Alongside it, ADR 0010 answers the second question android-port.md left open,
and it had to be answered before the first release rather than at upload time: a
new Play app must use App Bundles and therefore Play App Signing, and an
installed app can only be updated by a package signed with the same key, so the
first release picks an identity for good. The project holds the key, offline and
never in CI — the workflow's package step now says so where somebody would break
it — and a DodoSSH deployment never serves the client, because a download link on
your own server hands the binary that holds the plaintext to the party the whole
threat model is about.

The README's M1 gap note was stale in both halves and is replaced by what is
actually true: credentials have an editor and a REMEMBER tick, and the device key
registers into the TPM under a CNG policy that makes the consent dialog a
condition of using it. What is left is the floor rather than a gap — no TPM, or
no Windows, means the passphrase on every launch.
2026-08-04 10:07:16 +02:00
jaap-jan 2a56ae3efb Put a host inside the group it is filed under, rather than beside it
The group cards were headings with a navigation gesture bolted on. Opening one
narrowed the grid to its hosts, but the level above already held every host in
the keychain — so a card could only ever subtract, filing something changed
nothing but a chip, and a keychain with forty machines was forty cards however
carefully anybody had arranged them. The trail said ALL HOSTS and meant it.

So the grid holds one level of the tree, the way a directory pane holds one
directory. A host filed under a group is inside that group and is not also on
the screen the group's card sits on; the outermost level is what nothing has
been filed into. A group is a place now, and the cards, the trail and the drop
target were already the vocabulary for saying so.

The find box is the exception and had to be one. Typed into, it searches the
open group and everything under it, which from the outermost level is every
machine in the keychain. A box scoped to the level it was typed on would answer
"no host matches that" about a host this keychain has got, and finding a machine
without first remembering where it was filed is most of what the box is for.
The accent chip on a card is what tells a searched-up host from one that lives
at this level.

Two things came free with the change and are handled rather than left.

The phone would have broken. Its list binds SidebarRows, which was a projection
over VisibleHosts, and it has no group cards and nowhere to open one into — so
level-scoping would have left it drawing only the hosts nobody had filed.
RebuildSidebarRows takes its own pass over the hosts now, narrowed by the vault
switches and the box and by nothing else, which is the whole tree flattened
under headings: exactly what it drew before.

And anything created inside a group disappeared the moment it was saved. The
host editor opens on the group the screen is about rather than only on a
selected card, and + NEW GROUP defaults its parent to the group that is open.
Deliberately not the selected card there: a highlighted card is what EDIT and
DELETE are aimed at, and reading it as "and the next group goes inside it" would
nest one because somebody had clicked something. The host editor takes both
because it always took the selection, and its picker shows the answer before
anything is written.

Dropping a host on a group card now takes the card off the grid, and the
selection goes with it — Connect, Edit and Delete all read that property and
none of them should be aimed at a card that has left the screen. The status line
is what says where it went, which is why the manual check now asks for it to be
read. Coming back out through the editor lands the host on this level again, and
there the selection survives.

An empty grid has two more things it can say: that every host is filed away,
which the level-at-a-time grid made reachable and which is not the same sentence
as "there are none", and that nothing under this group matches what was typed —
with ALL HOSTS named as the way to widen it.

Not changed, and next door: a group card counts the hosts filed directly under
it, so a group holding only subgroups reads "0 hosts". That was already true and
is more visible now that its subgroups' hosts are not spilled onto the level
above.

1522 tests pass. Four are new — the level rule and the phone's flat list
asserted together, the box reaching two levels down, the all-filed sentence, and
where a host and a group made inside a group end up. MovingAHostToAGroup was
asserting the old outcome and is rewritten rather than adjusted: it held that
the host stayed selected, and what it holds now is that the host leaves the
level it came from.
2026-08-04 09:51:55 +02:00
jaap-jan c86c4dc6ec Merge branch 'claude/team-key-share-rotate-4b2619'
ci / build and test (push) Successful in 1m35s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 37s
2026-08-03 23:05:55 +02:00
jaap-jan d5b1a73182 Move the keys when a membership changes, not just the flag
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.

The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.

The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.

What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.

Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
2026-08-03 23:05:40 +02:00
jaap-jan e82a25c912 Merge branch 'claude/vault-creation-sharing-62c0b6'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s
2026-08-03 21:53:09 +02:00
jaap-jan 7b7fd7b2ef Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way
round. A vault has to belong to a team, so creating one meant going to the teams
screen, founding an organisation, and only then adding a vault to it — which the
NEW VAULT button named after the team, so a team with three of them held three
vaults called the same thing and nothing told them apart. Somebody who wants to
share four servers with two colleagues is not asking to found anything.

So the form asks for a name and nothing else. The team is derived from it, slug
included, and created with this account as its owner; the vault goes inside; and
the members, roles, invitations and key holders that hang off a team are all on
screen the moment it exists. The tab strip's New vault entry lands there with the
new vault selected, which is where the next thing anybody wants to do already is.

That is two calls, and the first can succeed alone. When it does the team is
kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again
resends the identical create — which the server treats as the same team — and
retries the vault, and the message says all of that rather than "creating the
vault failed". Archiving the orphan instead would be a client deleting something
on the user's behalf because a later step failed, which is the kind of tidying
that eventually archives a team somebody has just been added to. A slug taken by
somebody else is retried once with a disambiguated one and never in a loop; a
name with no a-z or 0-9 anywhere in it falls back to the team's own id rather
than to a refusal pointing at a field nobody was shown.

The other half is the caret beside Vaults. Being in four teams means four teams'
machines in front of you all day, and the answer is a switch per vault rather
than four sign-ins. Switching one off takes its hosts, groups, keys and pins off
the screens that list them and does nothing else: it still syncs, its key stays
in the keyring, it stays choosable as somewhere to file a new item, and a shown
host that authenticates with a key filed in it still connects. That last one is
what shaped the design. TryBuildAuthentication resolves a binding out of the
keychain's typed list and a cross-vault binding is legal, so filtering the reload
loops — the obvious implementation — would have turned a preference about reading
into an outage. Only the projections a person reads consult IsVaultShown; every
Reload*Async stays whole, including the dialled-endpoint set that decides which
pins are described as unused, because that is a hint which invites deleting
trust.

Snippets, logs and buckets needed no code and the comment says so out loud: all
three read ActiveVaultId alone, and the personal vault is drawn in the menu
ticked and cannot be switched off — it is the active vault, the group and tag
editors' target, and the save picker's fallback, so hiding it would empty half
the application rather than filter it.

The preference is a column on the cache's vault row, which is what makes it
survive both a relaunch and the /me refresh that runs every minute: Apply does
not touch it, deliberately, because the server has never been told which vaults
this machine is showing. It is in the encrypted cache rather than settings.json
because it is a list of vault ids and that file's own doc comment says what may
go in it. VaultSession cannot see the type at all — ReadableVaults is what the
sync loop walks, and a filter reaching it would be a vault that quietly stopped
syncing, found out weeks later from a host that was never there.

The strip's note refusing a MenuFlyout stands and is unchanged. This flyout
sidesteps the question rather than answering it: the handler selects the Vaults
tab first, which collapses the renderer, so nothing native is under the popup by
the time it opens — the move QuickConnect already makes. A headless test asserts
that ordering, which is as far as headless can go with no native window, and
manual check 1.6 is the other half.

The phone is out of scope on purpose: it has no tab strip and its teams screen's
vault section is read-only. The plumbing is in Client.Shell, so it can adopt this
later; until then nothing there is ever hidden, which is today's behaviour.

1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth
naming are the guards: a hidden vault still syncs, still holds keys that
authenticate hosts on screen, still appears in the save picker, and still counts
towards which pins nothing dials.

Not fixed, and noted here because it is next door: VaultGrantService's team-vault
create refuses a taken vault id rather than returning the existing vault, while
VaultSharing's own remark claims a create whose response was lost is safe to
resend. A lost 200 therefore leaves a vault whose key the client's catch already
zeroed, openable by nobody.
2026-08-03 21:52:27 +02:00
jaap-jan aa06868b1e Add the member the directory cannot see, rather than inviting them
ci / build and test (push) Successful in 1m30s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 33s
ADD MEMBER quietly issued an invitation instead of adding anybody, for everyone
who had signed in here and not yet enrolled. The screen told them that address
had no account, the members list did not change, and the person only actually
joined on the next hourly sweep.

The client decided whether an address had an account by asking the public-key
directory, and the directory answers a narrower question than that. It drops
every account with no current key — deliberately, because an entry exists to be
wrapped to and one carrying no key is a check a caller forgets exactly once. An
account exists from its owner's first authenticated request and publishes
nothing until they choose a passphrase on their own machine, so every account is
missing from the directory for that whole window and some indefinitely. A miss
there is not an absent account, and reading it as one was the bug.

The server would have taken the add. TeamService.AddMemberAsync only requires
the account row, and TeamMemberSummary.IsEnrolled exists precisely so a member
with no key can be listed — added on Monday, enrolled on Tuesday. The client
never asked.

So the directory is still asked first and the miss is retried as an add by
address, and only a server saying there is no such account reaches the
invitation. AddTeamMemberRequest gained an Email used when UserId is empty. The
lookup-first ordering is kept because it is load-bearing for sharing and not for
this: the key verified before a vault key is wrapped is the one the lookup
returned, and nothing is wrapped by adding somebody. That is why resolving the
address server-side is safe here and would not be there.

NoSuchAccount is its own code rather than folded into InvalidTeam, because it is
the one add failure the caller can act on unprompted — there is nobody to add,
so invite them — and a code shared with a rejected role would leave them
guessing which had happened. It does answer whether an address has an account
here, which CreateTeamInvitationRequest deliberately does not. That is the
property traded for the fix; the exposure is bounded by the admin check the add
already needed, and it is the same fact the member list shows a moment later.
Adding by a user id that does not exist now answers 404 no-such-account rather
than 400 invalid-team, and nothing depended on the old pairing.

The two silent returns are gone. Offline and no-team-selected set nothing and
returned, so those failures were visible only as a flicker of the busy flag —
which reads as a button that does nothing at all. The success line reads the
enrollment flag too, because pointing an unenrolled member at SHARE KEY is
pointing at a button that will refuse; their row already says it holds no key.

Why nothing caught it. FakeVaultServer had one list, so it could not tell an
account that does not exist from one that exists and has not enrolled — the
distinction this whole path turns on — and every account it knew was enrolled by
construction. It grows an accounts list beside the directory and reports
IsEnrolled from whether the directory has them, rather than hardcoding true. The
regression test asserts Invitations is empty, which is what fails against the
old behaviour. Four tests: that pair in the shell suite, and in the API suite an
unenrolled account added by address after its own directory lookup comes back
empty, and an unknown address refused under the new code. 1495 tests pass.
2026-08-03 17:04:03 +02:00
jaap-jan 1f2607cc9a Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
ci / build and test (push) Successful in 1m18s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 22s
2026-08-03 16:32:27 +02:00
jaap-jan 01d54ba4fa Put the trail above the GROUPS label rather than under it
It was drawn as the transfers screen's is, under the heading of the list it
belongs to. That is right there and wrong here: over there a path describes the
one pane beneath it, and this one describes two. Opening a group narrows the
host grid as well as the row of cards, so a trail sitting under GROUPS reads as
one more fact about the groups while it is also the reason the grid at the
bottom of the screen is showing eleven machines instead of forty.

So it is the first thing in the section, and both headings below — GROUPS and
HOSTS — belong to it. Markup only: the trail moved, the note on it says why it
is not placed like its sibling, and the group buttons' note now points up rather
than down. 85 layout tests pass, including the one that presses a rendered crumb
and would have caught the $parent binding failing to resolve from its new place.
2026-08-03 16:32:09 +02:00
jaap-jan 68600dea07 Merge branch 'claude/fingerprint-enrollment-support-a6c2f3'
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 36s
2026-08-03 16:31:46 +02:00
jaap-jan fd6c4f5155 Merge branch 'claude/host-detail-pane-design-e98621' 2026-08-03 16:31:43 +02:00
jaap-jan a2c56de1f2 Read the selected team once per reload, so its owner is listed once
Creating a team drew its owner twice. ReloadAsync rebuilds the team list and
then reselects, and the selection handler answers that assignment by starting
its own read of the members, invitations and vaults — while ReloadAsync is
awaiting a read of exactly the same thing. Both clear the collections up front
and both append when their round trip answers, so everything below the team list
was drawn twice. On a team nobody has been added to yet, whose only member is its
owner, that read as the owner being in the team twice.

The rows are records, so the reselect only raises a change when something about
the team has actually moved — which is every reload that follows a change:
creating a team, adding a member, renaming one. A plain refresh looked fine, and
the screen doubled precisely after the acts people come to this screen to
perform.

isReselecting suppresses the handler for the length of the assignment rather
than deduplicating rows afterwards, because only one of the two reads is
awaited. A command that reloads and then reads Members has to be looking at the
reload's own read and not at a fire-and-forget one that may not have answered.

The generation counter is the other half, and it is a different bug with the
same cause: selecting a second team before the first has answered leaves two
reads in flight against the same collections and nothing decides which wins, so
team A's members could land in the list under team B's name. A superseded read
now drops its answer instead of appending it.

Why nothing caught this. The fake server answers from memory, so every read
completes before the next begins and the appends can never interleave — the
duplicate needs a round trip to hold two reads open at once. FakeVaultServer
grows a MemberReadGate for that, and the new test holds a read open and counts
the reads in flight, which is the only moment a second one is distinguishable
from the first. It fails against the old behaviour with two. 237 tests pass in
the app suite and 83 in the layout suite.
2026-08-03 16:26:52 +02:00
jaap-jan e3dfe5c371 Make the host pane something you ask for, and draw it as cards
THE DRAWER USED TO ARRIVE WITH THE SELECTION. IsDrawerOpen read "a host is
selected", so touching any card took 304 pixels off the grid — including every
card arrowed past on the way to the one somebody wanted. Choosing among forty
machines was charged the price of the pane for one of them. A pencil now appears
on the card under the pointer and on the selected card, and that is what opens
it; IsHostPaneOpen is the flag, and the grid's context menu gained Details… so
the pane is reachable without a pointer, which a hover-only control is not.

Once open it follows the selection rather than pinning the host it was opened
on: a pane about one host beside a grid marking a different one is two answers
to the same question. Losing the selection closes it and clears the flag, or a
filter matching nothing would leave the pane armed to spring open again on the
next card merely selected — which is the behaviour the pencil exists to remove.

The pencil is drawn over the card rather than in a column of its own. A column
would have cost the name 30 pixels of a 232-pixel tile, permanently, for a
control that is only there while the pointer is; the dot and the pencil stack in
the two corners of that edge instead. IsVisible and not opacity, because a
button at zero opacity still takes the click and the card underneath does not.

A HEADER, A BODY THAT SCROLLS, AND A FOOTER, which is the one structural change
in the pane. The header names what the drawer is about and carries the two
things true of every panel; the footer carries the one thing each panel is for —
CONNECT, or SAVE, or the question about deleting. Only the middle scrolls, so
the button somebody came here to press can no longer be below the fold, which
CONNECT could be on a host with fifteen tags. That also widens what the layout
harness certifies: it skips anything inside a ScrollViewer, and the control each
panel exists to offer is now outside one.

THE SAME THREE CARDS TWICE. Address, General, Connection — first as rows stating
what the host is, then as boxes for changing it. The detail pane's rows are
buttons that open the editor: the design draws every fact as a filled box, and
rather than draw an input that refuses the pointer, pressing one leads to the
same card with a real box in it. Nothing here saves as you type, and that is not
timidity — saving validates the key-or-credential exclusion and writes one
encrypted payload, so a box committing per keystroke would be a save per
character and a half-typed hostname on the wire.

Every value the pane prints is the resolved one, and says "inherited" beside it
where a group supplied it. The number is the same either way and the edit is
not: clearing a group's default moves every host that never overrode it.

A HOST CARD IS TWO LINES AND NO CHIPS. The subtitle is now "ssh, root, pci,
eu-west-1" — the transport, the resolved account, then every tag — replacing
both the user@host:port line and the wrapped row of tag chips under it. The
address went to the card's tooltip rather than nowhere: a card is read while
scanning forty machines, where the name and the kind of machine are what is
being looked for, and an address is what you read once you have found it.

"ssh" is a constant today and is printed anyway, which is the one thing here
that argues with this codebase's own rule about constants dressed as readings.
It is the first item of a list whose other items vary, and a list beginning with
the account on one card and a tag on the next has no shape to scan. The remark
on HostRowViewModel.Summary says so rather than leaving it to be discovered.

WHAT THE DESIGN DRAWS AND THIS PANE HAS NOT GOT: Share this host, Add Telnet,
"SSH ID, Certificate, FIDO2", the backspace-key mapping row, the vault picker's
chevron and Show more. Sharing is per vault and not per item, every session here
is an SSH channel, there are no identity or certificate item types, nothing
carries a terminal setting to the renderer, and an item cannot be moved between
vaults at all. Six controls with nothing behind them, listed in
docs/design-import-gaps.md with what ships instead, and none drawn disabled.

The credentials row is marked with ◆ rather than the ⚿ the nav rail uses for the
keychain. U+26BF is outside both faces this application substitutes for the
design's fonts, so it lands on whatever the platform's fallback has; every other
glyph in the pane is from Geometric Shapes, which both carry.
2026-08-03 16:26:43 +02:00