diff --git a/docs/adding-hosts-on-the-phone.md b/docs/adding-hosts-on-the-phone.md
index ce1d417..802856c 100644
--- a/docs/adding-hosts-on-the-phone.md
+++ b/docs/adding-hosts-on-the-phone.md
@@ -299,7 +299,7 @@ because-string used as prose.
`Sync.Tests/HostCipherTests.cs`. There is no shared group factory and with five new fields there should be.
- Three guard tests fail by design and that is what they are for:
`HostSecretMergeTests.EveryScalarField_IsRoutedThroughAMerge` (note `GroupId` is missing from its `with`
- block today — an existing gap worth closing while there),
+ block today — an existing gap worth closing while there) *(closed: `GroupId` is in that block now)*,
`ValueSemanticsTests.TryValidate_RejectsWhatCannotBeStored`, and the two "groups are flat" remarks.
- `ItemKindsTests.ThePullFilterNamesEveryTypeThisBuildSynchronises` is an ordered list — add Tag in registry
order. `AadResourceTypeTests` needs `PinnedPairs`, a `SealSample` arm and a `NewTag()` builder.
@@ -307,7 +307,9 @@ because-string used as prose.
with a reason containing "flat". Putting the parent **inside the payload** does not break it mechanically,
but its stated reason becomes wrong. It should survive with a rewritten comment — the parent lives in the
payload, the plaintext column stays refused, and ADR 0004 is why. Decide that deliberately rather than
- letting it drift.
+ letting it drift. *(Decided that way, and the name says it now:
+ `SyncEndpointTests.AHostGroupCarryingAPlaintextParent_IsRejected`. The column is still refused and the
+ reason is the trust model rather than flatness.)*
- `Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch` picks the first type with no kind registered, very
likely Tag today. It self-heals to `HostTag` via `Assert.SkipWhen`.
- `App.Layout.Tests` measures pixels and bends rather than breaks. Extend the seeders — nesting and a tag row
@@ -323,6 +325,15 @@ because-string used as prose.
`ConflictStore.Record` throws `ArgumentOutOfRangeException` on a conflict for any of those three. Pre-existing,
unrelated to any of this, and `Tag` is already in that switch. Worth a separate fix.
+> **Fixed 2026-08-04**, having outlived the phases that shipped the logs and the buckets — which is exactly
+> the drift a note like this is meant to prevent, so it is worth saying what let it last. Of the three callers
+> of `AadResourceTypes.For`, two reach it only when an item carries plaintext fields and none of these three
+> does; the third, `ConflictStore.RecordAsync`, calls it unconditionally but is reached only by a real merge
+> conflict, which every existing test raised against a `Host`. The arms are in, and two tests now hold them
+> there: `CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem` records one per kind, and
+> `AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping` fails on the *next* item type added
+> without one, by name rather than by a list kept by hand.
+
## Prose that becomes false
Not a tidy-up — these are the places the codebase currently tells a user or a maintainer that this feature is
diff --git a/docs/android-port.md b/docs/android-port.md
index eb7f7d5..dbebd99 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -333,7 +333,11 @@ that appear rarely and matter most. Those are marked **◆**.
- **Status bar** — the selected terminal's live dot and address, the vault's last status sentence, the sync
label again, and the search hint.
- **Nav rail** — eight destinations: `HOSTS FILES KEYS PINS SNIPS LOGS TEAM PREFS`. Five characters is a
- desktop constraint, not a product one; the phone can use words.
+ desktop constraint, not a product one; the phone can use words. ⚠️ **Corrected:** the desktop's v3 moved
+ FILES and the S3 buckets out of the rail and onto fixed tabs beside the terminals, leaving seven — they are
+ the two destinations you *stay in* while something runs, and a rail entry is for somewhere you go and come
+ back from. The phone reached the same count by a different route: three bottom-bar destinations with the
+ rest behind Settings.
- **Terminal strip** — always visible, above every screen. Tabs with a close cross *inside* each tab, a `+`,
and a sentence when there are none. This is what makes a terminal a surface the window switches to rather
than a screen you navigate away from, and it is the single most desktop-shaped idea in the product.
@@ -341,10 +345,21 @@ that appear rarely and matter most. Those are marked **◆**.
### The nine destinations
+> ⚠️ **This is the desktop as it stood when the audit was taken, and it is left that way on purpose** — it is
+> the checklist the scope was decided against, so rewriting it would erase what was actually weighed. Two
+> things have moved since, and both are noted where they belong below: the rail lost FILES and S3 to fixed
+> tabs in the desktop's v3, and the hosts sidebar became a grid of cards. Neither changed what a phone needs
+> somewhere to put, which is what this list is for.
+
**1. Hosts** — the list of machines, and what is known about the selected one.
- *Sidebar:* filter box; group headings with a chevron and a count, **shown only when groups exist**; host
rows carrying a connected dot, name, sync badge, address and one word for how it authenticates.
+ ⚠️ **Corrected:** the desktop replaced this column with a grid of cards and a right-hand drawer, and its
+ grid holds one level of the groups at a time — so the headings, their chevrons and UNGROUPED are now the
+ *phone's* alone, drawn from `SidebarRows`. That is what shipped here, and it is why this head flattens the
+ whole tree rather than navigating into it: there is no room for a row of group cards at 360dp and nothing
+ to open one into. See `docs/design-import-gaps.md` on the desktop's v3.
- *Editor* (doubles as "add"): name, hostname, port, username, notes, one authentication picker covering
typed password / key / stored credential, a group picker, a relay checkbox with the sentence explaining
that relay puts the address on the server in plain text, and **◆ forget host key** — the only way back from
diff --git a/docs/crypto.md b/docs/crypto.md
index 0e387b9..c507ce5 100644
--- a/docs/crypto.md
+++ b/docs/crypto.md
@@ -300,7 +300,7 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
-`13` HostCredential.
+`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore.
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
@@ -308,6 +308,17 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
> implementation to need one would have had to invent a value or reuse a neighbour's. Append
> only, and no such item has been stored.
+> **Recorded 2026-08-04:** `14`, `15` and `16`. Unlike `12` and `13` these were not a hole — they
+> were added to `CryptoSpec.AadResourceType` when the two logs and the buckets shipped, and items
+> **have** been sealed under them. What was missing was this table, which had gone three item kinds
+> without being updated. Nothing changes on the wire or on disk; the normative list now says what
+> the code has been doing.
+>
+> **They are the first members that are not a fixed offset from their `SyncEntityType`
+> counterparts** — the two enums drifted apart when `12` and `13` closed the hole above, so
+> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
+> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
+
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
### 4.4 What this prevents
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index f2b46fd..6eda9a5 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -105,6 +105,13 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> double-pressing one opens it, and a breadcrumb trail above the cards is where you are and the way back —
> the same trail the transfers screen draws over a directory.
>
+> **And the grid holds one level of that trail rather than every host.** A host filed under a group is drawn
+> inside that group and not also on the screen the group's card sits on, which is what makes a card a place
+> instead of a label: while the level above held everything anyway, opening a group could only ever subtract.
+> The find box is the one thing that crosses a boundary — it searches the open group and everything under it,
+> so a machine can be found without first remembering where it was filed, and the chip on a card is what says
+> which group a result came out of. See `VaultViewModel.Matches`.
+>
> **The type scale went up a point and the text ramp went white.** `#E3E7F4` was a blue-tinted white on
> blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing
> a hue with what it is drawn on. Every step of the ramp moved with the top, so the intervals the design
@@ -298,9 +305,9 @@ caption buttons and window title drawn on top of the application's own — two s
| Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- |
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **Shipped.** `Tag = 5` is a live item kind and `HostSecret.TagIds` names them, merged per tag so two people tagging one host both keep theirs — which is what `HostTag = 6` was going to buy, so it stays reserved and unused. Chips are drawn on host rows on both heads and toggled in the host editor, which also creates one inline; renaming and deleting are a TAGS category on the keychain screen. The filter box still searches name, address and notes only — a chip is read rather than typed. |
-| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Nested in the data and drawn flat — a group carries a `ParentId` and the defaults its hosts inherit, and the sidebar still emits one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
+| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. A group carries a `ParentId` and the defaults its hosts inherit, and the two heads draw the nesting differently on purpose. The desktop is a grid of cards holding one level at a time, the way a directory pane holds one directory; the phone has no room for a row of cards, so it flattens the whole tree into one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before on both — one flat list, no headings and no cards. |
| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
-| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a host a teammate filed still shows under UNGROUPED and cannot be renamed or unfiled from here. |
+| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a group a teammate made has no card and no heading and cannot be renamed from here. A host filed into one is drawn at the outermost level of the desktop's grid and under UNGROUPED on the phone — reachable either way, which is the point — with the chip on its card still naming the group, because the label is resolved through the wide map while the level is decided from the narrow list. |
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 2806974..59b1121 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -16,6 +16,12 @@ Three constraints put things on this list, and they are worth knowing before add
Each item says what to do, what a pass looks like, and what a failure would mean.
+**On the numbering.** A check keeps its number for life, because code comments and other documents cite them
+— `HostGridTests` sends a reader to 7.6, `platform-flags.md` to 3.6–3.8. A check inserted later therefore
+takes a letter rather than pushing its neighbours along: 3.2a and 3.2b sit between 3.2 and 3.3 and always
+will. Add in the same way, and keep each one next to the check it belongs beside; a gap in the numbers means
+a phase had nothing left for a person to do, which is the good outcome rather than an omission.
+
---
## Phase 1 — the shell and the tab strip
@@ -290,11 +296,12 @@ surface. Use the ← button, or drag the file to the local pane first.
## Phase 3 — Host groups and snippets
-Two synced item kinds, a sidebar that now draws headings, and one new frame between the host process and the
-renderer. The data half of all of that is covered: the payloads round-trip, the server refuses the plaintext
-fields, the sidebar's grouping and the snippet policy are in `ShellFlowTests`, and both new screens are
-measured. What is left here is the part that only exists inside a WebView, plus the two-machine cases no
-single-process test can reach.
+Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
+the phone's flat list under headings — and one new frame between the host process and the renderer. The data
+half of all of that is covered: the payloads round-trip, the server refuses the plaintext fields, both ways
+of drawing a group and the snippet policy are in `ShellFlowTests`, and both new screens are measured. What is
+left here is the part that only exists inside a WebView, plus the two-machine cases no single-process test
+can reach.
### 3.1 A keychain with no groups looks exactly as it did
@@ -334,19 +341,6 @@ a filed host is worse than either: it is the box answering "no host matches that
keychain has got. A full-width bar with a chevron between the cards is the old grouping coming back through
`SidebarRows`; the desktop grid binds `VisibleHosts`. See `HostsScreen.axaml`.
-### 3.2b Making something while standing inside a group
-
-Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**.
-
-**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on
-the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that
-group as its parent, so the new group is a card inside the one that is open.
-
-**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut
-a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate
-difference between them: the host editor also takes a merely *selected* card as its group, the group editor
-takes only the group that is open.
-
### 3.2a A group inside a group, and the way back out
Make two groups and file one under the other with the parent picker in the group editor.
@@ -361,17 +355,18 @@ Pressing the **middle** crumb goes back one level rather than all the way out.
the flat `Groups` is the phone's and the lookups'. A group that cannot be reached at all is worse and is the
case `EffectiveParents` promotes: see 3.4a.
-### 3.4a A parent that dangles, or a cycle · **needs two machines**
+### 3.2b Making something while standing inside a group
-Rare, and the reason it is here is that the repair for both is inside the group's own editor, which is
-opened from its card. On A, file group X under group Y and sync. On B, sync, delete Y, sync. Back on A, sync.
-For the cycle: with both machines offline, file X under Y on A and Y under X on B, then sync both.
+Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**.
-**Pass:** in both cases every group still has a card at the outermost level, so every one of them can be
-opened and its parent cleared. Nothing is missing from the grid, and the trail never loops.
+**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on
+the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that
+group as its parent, so the new group is a card inside the one that is open.
-**Failure means:** a group drawn nowhere is a group that can never be repaired — the fix is locked inside the
-state that needs fixing. See `VaultViewModel.EffectiveParents`.
+**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut
+a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate
+difference between them: the host editor also takes a merely *selected* card as its group, the group editor
+takes only the group that is open.
### 3.3 Deleting a group with hosts in it
@@ -394,6 +389,18 @@ the group picker shows "(a group that is no longer here)" and *keeps it selected
**Failure means:** if the picker opened on "No group", saving has just unfiled the host — quietly, as a side
effect of an unrelated edit. That is the case `BuildGroupChoices` adds the placeholder for.
+### 3.4a A parent that dangles, or a cycle · **needs two machines**
+
+Rare, and the reason it is here is that the repair for both is inside the group's own editor, which is
+opened from its card. On A, file group X under group Y and sync. On B, sync, delete Y, sync. Back on A, sync.
+For the cycle: with both machines offline, file X under Y on A and Y under X on B, then sync both.
+
+**Pass:** in both cases every group still has a card at the outermost level, so every one of them can be
+opened and its parent cleared. Nothing is missing from the grid, and the trail never loops.
+
+**Failure means:** a group drawn nowhere is a group that can never be repaired — the fix is locked inside the
+state that needs fixing. See `VaultViewModel.EffectiveParents`.
+
### 3.5 A grouped host stays editable on an older build · **needs two builds**
Only worth doing before a release that ships alongside an older client. A host filed into a group is written
@@ -753,7 +760,7 @@ wheel. The pointer has to keep moving inside the band — a stationary pointer g
**Failure means:** headless Avalonia cannot synthesise a platform drag, so the picking up, the cursor and
the drop are covered by nothing. What *is* automated is the decision each drag event takes —
`HostGridTests.TheGroupCardsAreWhatAcceptsADroppedHost` raises a real `DragOver` over both kinds of card —
-and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected`.
+and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom`.
### 7.7 A click still selects, and a double click still connects
@@ -804,6 +811,8 @@ add a bucket — then come back.
**Failure means:** the screen has gone back to copying the vault's lists once at unlock. Covered by
`TheTransfersScreen_FollowsTheVaultsHostList`; this is the version of it with a real picker in front of it.
+---
+
## Phase 8 — Adding hosts on the phone
Nine checks, and the reason there are nine rather than none is worth stating: **the layout suite cannot see
@@ -902,6 +911,8 @@ Walk the sheet's two rows, the editor's fields, both pickers, the checkbox and t
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.
+---
+
## Phase 9 — Tag chips and the picker
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
diff --git a/src/DodoSSH.Client.Storage/LocalCacheProtector.cs b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs
index d300c71..d9352a0 100644
--- a/src/DodoSSH.Client.Storage/LocalCacheProtector.cs
+++ b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs
@@ -98,10 +98,27 @@ public sealed class LocalCacheProtector : IDisposable
/// Maps a syncable entity type onto the resource type its AAD binds.
///
///
+///
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
/// same list: also covers users, devices and vaults, so the
/// numbers do not line up, and a cast would bind an item's ciphertext to the wrong resource type
/// without failing anywhere a test would notice.
+///
+///
+/// It has to name every syncable type, and the throw is not a safety net.
+/// ConflictStore.RecordAsync calls this unconditionally, so a type with no arm here is a type
+/// whose conflicts cannot be recorded — and the conflict log is the only reason the merge is
+/// allowed to pick a winner at all. Three types went two phases without one: the two logs and the
+/// buckets were added to both enums and to the reconciler registry, and this switch was not touched,
+/// so a merge over any of them turned a recorded conflict into an
+/// . The two other callers hid it —
+/// ItemStore and OutboxStore only reach this when an item carries plaintext fields, and
+/// none of those three does.
+///
+///
+/// AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping is what says so now, and it
+/// is a name comparison rather than a list to keep by hand.
+///
///
internal static class AadResourceTypes
{
@@ -117,6 +134,14 @@ internal static class AadResourceTypes
SyncEntityType.Snippet => CryptoSpec.AadResourceType.Snippet,
SyncEntityType.PortForward => CryptoSpec.AadResourceType.PortForward,
SyncEntityType.KnownHostKey => CryptoSpec.AadResourceType.KnownHostKey,
+
+ // Appended in the order the enums grew, which is why these three are not beside their
+ // neighbours by number. See docs/crypto.md §4.3 on why 14–16 are not one-behind their wire
+ // counterparts the way the arms above are.
+ SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry,
+ SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry,
+ SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore,
+
_ => throw new ArgumentOutOfRangeException(
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
};
diff --git a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
index 90353a2..2360613 100644
--- a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
+++ b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
@@ -394,6 +394,49 @@ public sealed class CacheStoreTests : IAsyncLifetime
all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
}
+ ///
+ /// A conflict can be recorded against any kind of item, not only the kinds that were here first.
+ ///
+ ///
+ ///
+ /// Every other test in this section uses , and that is how three item
+ /// kinds shipped with no way to record a conflict at all: the two logs and the buckets were added to both
+ /// enums, to the reconciler registry and to the cipher pinning, while AadResourceTypes.For — which
+ /// ConflictStore.RecordAsync calls unconditionally — kept throwing for them. The two other callers
+ /// of that mapping only reach it when an item carries plaintext fields, which none of the three does, so
+ /// nothing else so much as touched the gap.
+ ///
+ ///
+ /// A theory over the types rather than one more Host case, because the failure was never about
+ /// conflicts and always about which types the layer below had been taught. Recording is asserted through
+ /// a read-back rather than by "it did not throw": an arm returning the wrong resource type would seal
+ /// under one AAD and open under another, which is a null detail rather than an exception.
+ ///
+ ///
+ [Theory]
+ [InlineData(SyncEntityType.Host)]
+ [InlineData(SyncEntityType.HostGroup)]
+ [InlineData(SyncEntityType.Snippet)]
+ [InlineData(SyncEntityType.ConnectionLogEntry)]
+ [InlineData(SyncEntityType.ActivityLogEntry)]
+ [InlineData(SyncEntityType.ObjectStore)]
+ public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
+ {
+ var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");
+
+ var id = await harness.Conflicts.RecordAsync(
+ VaultId, entityType, Guid.CreateVersion7(), ConflictKind.FieldOverridden, detail, Token);
+
+ var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
+
+ listed.Id.ShouldBe(id);
+ listed.EntityType.ShouldBe(entityType);
+ listed.Detail.ShouldBe(
+ detail,
+ "an empty detail here means the record was sealed under one resource type and opened under "
+ + "another, which ListAsync reports as nothing rather than as a failure");
+ }
+
[Fact]
public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
{
diff --git a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs
index 28c86de..a51df87 100644
--- a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs
+++ b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
+using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
@@ -151,6 +152,63 @@ public sealed class AadResourceTypeTests
.ShouldBe(ItemKinds.SyncedTypes, ignoreOrder: true);
}
+ ///
+ /// The same pairing, made a second time in the storage layer, and every type must be in it.
+ ///
+ ///
+ ///
+ /// AadResourceTypes.For is the cache's copy of the table above: the ciphers seal an item for the
+ /// server, and this seals the two things the local cache holds in the clear — a relay host's
+ /// address, and the values a merge overrode. A type missing from it throws rather than mis-seals, which
+ /// sounds like the safe failure and is not: ConflictStore.RecordAsync calls it unconditionally, so
+ /// the exception lands on the path that records what a merge discarded.
+ ///
+ ///
+ /// This is written after finding three types missing from it — ConnectionLogEntry,
+ /// ActivityLogEntry and ObjectStore went two shipping phases without an arm, because the
+ /// only unconditional caller is one a test suite reaches solely by causing a real merge conflict. Asserted
+ /// over the whole wire enum rather than over ItemKinds.SyncedTypes, which is the stronger claim and
+ /// the one the switch actually makes: the two reserved association types have arms too.
+ ///
+ ///
+ [Fact]
+ public void EverySyncableType_HasAnArmInTheStorageMapping()
+ {
+ var missing = new List();
+
+ foreach (var wire in Enum.GetValues())
+ {
+ if (wire == SyncEntityType.Unspecified)
+ {
+ continue;
+ }
+
+ CryptoSpec.AadResourceType resource;
+
+ try
+ {
+ resource = AadResourceTypes.For(wire);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ missing.Add(wire);
+ continue;
+ }
+
+ // Same name, as the cipher table demands — a wrong-but-present arm is the failure this half
+ // would otherwise wave through.
+ Enum.GetName(resource).ShouldBe(
+ Enum.GetName(wire),
+ $"AadResourceTypes.For({wire}) returns {resource}, which binds this type's cache records "
+ + "to another type's resource.");
+ }
+
+ missing.ShouldBeEmpty(
+ "every syncable type needs an arm in AadResourceTypes.For, or a conflict recorded against one "
+ + "of these throws instead of being written — and the conflict log is what justifies the merge "
+ + "picking a winner.");
+ }
+
private static EncryptedPayload SealSample(
SyncEntityType wire,
byte[] vaultKey,