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.
This commit is contained in:
2026-08-04 16:04:15 +02:00
parent e9cea2ccbc
commit bee6202949
9 changed files with 634 additions and 70 deletions
+60 -19
View File
@@ -71,11 +71,12 @@
One row for all three panels, which is why what it says is on the view model rather than repeated
three times here. See VaultViewModel.DrawerTitle.
The subtitle is the vault this host is filed in, and the design's chevron beside it is not drawn:
an item cannot be moved between vaults — the two are encrypted under different keys, so moving one
is a delete and a retype — and a picker offering the move would be offering something no layer below
this can do. Choosing the vault at the moment a host is created is a different question and does
have an answer; it is in the editor, beside the name.
The subtitle is the vault this host is filed in, and the design's chevron beside it is still not
drawn although a host can now be moved. The two are encrypted under different keys, so a move is a
re-seal into one vault and a tombstone in the other — it leaves the host's group and tags behind and
gives it a new id, none of which a chevron on a subtitle would lead anybody to expect. It is in the
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
moment a host is created is a different question, and it is in the editor beside the name.
-->
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
@@ -91,10 +92,15 @@
</StackPanel>
<!--
The host's own two actions, behind a menu rather than as a row of buttons under the pane. They
are what EDIT and DELETE were; a pane whose footer is CONNECT has one action worth a button, and
the other two are things you go looking for. It hides with the deletion question for the reason
the buttons did — see VaultViewModel.ShowsHostPaneActions.
The host's own actions, behind a menu rather than as a row of buttons under the pane. They are
what EDIT and DELETE were; a pane whose footer is CONNECT has one action worth a button, and the
rest are things you go looking for. It hides with the deletion question and with the move panel
for the reason the buttons did — see VaultViewModel.ShowsHostPaneActions.
Moving is here rather than in the editor, and the separator says which side of the line it is
on: it is not a field of the host. The two vaults are encrypted under different keys, so it is a
re-seal into one and a tombstone in the other — nothing a SAVE could do — and a picker inside
the form would let somebody correcting a port move a machine by leaving it where they found it.
-->
<Button Grid.Column="1" Classes="flat paneicon" Content="⋯"
IsVisible="{Binding ShowsHostPaneActions}"
@@ -102,6 +108,7 @@
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<MenuItem Header="Move to another vault…" Command="{Binding MoveHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</MenuFlyout>
@@ -365,12 +372,16 @@
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it is the one decision on this
form that cannot be changed afterwards: the vaults are encrypted under different keys, so
moving an item between them is a delete and a retype. It is a field of the host rather
than the keychain screen's standing "new items go to" preference, and it is a separate
selection from it — moving this one does not move that one, and a click over there cannot
move a host half-typed here.
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
because it is the one thing on this form that no later SAVE can change: the vaults are
encrypted under different keys, so changing it is a re-seal into one and a tombstone in
the other. That is offered — "Move to another vault…" in the pane's own menu — and it is
deliberately not this control, because a picker inside the form would move a machine as a
side effect of correcting a port.
It is a field of the host rather than the keychain screen's standing "new items go to"
preference, and it is a separate selection from it — moving this one does not move that
one, and a click over there cannot move a host half-typed here.
Shown only while adding, and only where there is more than one vault that can be written
to. An existing host's row is not drawn at all rather than drawn disabled; the drawer's
@@ -390,7 +401,7 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A host in a shared vault is readable by everybody holding that vault's key, and it cannot be moved out afterwards." />
Text="A host in a shared vault is readable by everybody holding that vault's key. It can be moved out later, from this pane's own menu — what it cannot do is become unreadable to somebody who has already synced it." />
</StackPanel>
<!--
@@ -602,13 +613,13 @@
<!-- ============ THE FOOTER ============ -->
<!--
One row, and exactly one of its four contents is showing — the same by-construction exclusivity the
One row, and exactly one of its five contents is showing — the same by-construction exclusivity the
panels above have, from the same flags. It is what each panel is for: connecting, saving a host,
saving a group, or answering the question about deleting one.
saving a group, answering the question about deleting one, or choosing where to move one.
◆ THE QUESTION TAKES CONNECT'S PLACE rather than stacking under it, as it always did with the row of
buttons this footer replaced, so that DELETE cannot be pressed again while its own question is on
screen. See VaultViewModel.ShowsHostPaneActions.
screen. See VaultViewModel.ShowsHostPaneActions. The move panel takes it for the same reason.
-->
<Border Grid.Row="2" Padding="12" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
@@ -624,6 +635,36 @@
<views:ConfirmDeleteCard />
</Border>
<!--
◆ MOVING THE HOST TO ANOTHER VAULT. A picker and two buttons, not a question with a yes: what
is being asked is which vault, and a move is undone by moving it back rather than by being
careful — so this is not drawn in the danger colours the deletion question uses.
The sentence under it is the part worth keeping. A group and a tag are items of the vault the
host is leaving, so neither can come; saying so here rather than only in the status line
afterwards is the difference between a warning and a surprise. What it deliberately does not
promise is anything about the key or password the host authenticates with — those resolve
across vaults, they are kept, and the status line names one that is left outside.
-->
<StackPanel Spacing="8" IsVisible="{Binding IsMovingHost}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveHostCommand}" />
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />