Build the three things the phone's + needs, before the + exists

Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing
on either head has changed, which is deliberate — the plan orders these first
because everything the editors will bind to has to exist and be merge-safe
before a screen can offer it.

HostGroupSecret gains a parent and four defaults, and the codec gains the
version rule it never had. It stamped CurrentSchemaVersion unconditionally,
which was harmless with one field and one version and stops being harmless
here: upgrading one machine and renaming any group would have made that group
uneditable on every machine still on the old build. It now emits the lowest
version that loses nothing, so a flat group with no defaults still encodes at
version 1, byte for byte, pinned against a literal.

Tags become a real item over the reserved slot. Secret, codec, merge, cipher,
repository, both registries, the EF entity and a generated AddTagItem
migration. TagCipher names AadResourceType.Tag as a constant rather than
casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum
and 5 there is Credential — a cast would seal every tag under the resource
type for a password, encrypt and decrypt perfectly on the machine that wrote
it, and only fail when another implementation refused the item, by which time
the AAD is frozen into stored ciphertext. HostTag stays reserved and unused:
the one thing the join buys over a set on the host is bought instead by
merging TagIds per id.

HostSecret grows TagIds and Port goes nullable, which is the change with the
widest blast radius and the only one that loses an item rather than locking
one. A host with no port of its own omits the property, an older build reads
int Port as 0, and TryValidate refuses it — unreadable rather than read-only.
That cost is confined to hosts which actually inherit, because the version is
a maximum over the fields present; the alternative, writing 22 into every
host, is the lie inheritance exists to stop telling.

One decision the plan did not specify. "Three states where there were two" is
four — key, credential, typed password, or the group's answer — and two
nullable ids carry three. Naming neither id now means inherit, so
AsksForPassword says "a typed password even under a group that lends a key"
out loud. Only true is ever written and a decoded false folds back to null, so
a host that never touched it encodes as it always did. Nothing already stored
changed meaning: no group could lend a binding before this build, so every
existing host resolves exactly as it did.

HostInheritance is the resolver, and its visited set is load-bearing rather
than defensive. Two clients can each re-parent A under B and B under A while
offline; the merge sees one item against one item and the server sees
ciphertext, so nothing upstream can refuse the pair. With inheritance the
chain is walked at connect time, so an unguarded cycle is not an undrawable
sidebar — it is a shell that never opens. Stopping at the first repeat
degrades it to a group that reads as a root, and clearing the parent is the
repair.

A tag set turns out to be the one field on a host that can never ask the user
anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so
the both-sides-moved-differently branch of the keyed merge is unreachable —
asserted over the whole eight-row matrix. The conflict loop is kept anyway,
because that proof is one edit from ceasing to hold and what it would cause is
a discarded tag nothing records.

Three guard tests failed by design and were fixed rather than relaxed: the
ordered pull filter, the AAD pinning table, and the server's refusal of a
plaintext parent — that last one survives with its reason rewritten, because
the refusal now means "the parent is not the server's to hold" rather than
"there is no such thing as a parent". The prose that said groups are flat is
rewritten in all four places it appeared, not deleted.

The five view-model sites that read Port directly now go through the resolver,
which is a down payment on step 4 rather than the whole of it. HostFields.From
still emits the stored port, and that is the one remaining place where an
unresolved read would be a wrong wire rather than a wrong label.

Verified by the whole suite: 1382 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display, because nothing on a display has
changed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 10:21:02 +02:00
co-authored by Claude Opus 5
parent c9eca96ce7
commit 8c04ba60b0
35 changed files with 4921 additions and 120 deletions
+92 -7
View File
@@ -327,15 +327,25 @@ public sealed class VaultKnownHostKey : IVaultItem
/// </summary>
/// <remarks>
/// <para>
/// A group is a name and nothing else, so this row is the narrowest one in the schema: an envelope and its
/// bookkeeping. There is no <c>parent_id</c> and no <c>name</c> column, and both absences are deliberate.
/// Everything a group is — its name, the group it sits under, and the port, username and binding hosts
/// inside it fall back to — is inside the envelope, so this row is one of the narrowest in the schema: an
/// envelope and its bookkeeping. There is no <c>parent_id</c> and no <c>name</c> column, and both absences
/// are deliberate.
/// </para>
/// <para>
/// <b>No parent, because groups are flat.</b> A nesting pointer merged by a scalar three-way merge lets two
/// offline clients each re-parent A under B and B under A, and the result is a cycle the server cannot see —
/// the pointer would be inside the payload, which the server cannot read — and which every client would then
/// have to detect on every read, forever. Flat costs one level of organisation and removes a whole class of
/// unrepairable state.
/// <b>No parent column, although groups do nest.</b> The pointer exists; it lives in the payload, and the
/// push path refuses a plaintext <c>ParentId</c> outright. What a column here would hand the operator is the
/// shape of every user's estate — how many groupings, how deep, which under which — which is the same
/// disclosure a plaintext <c>group_id</c> on a host would have been, and ADR 0004 spends the one plaintext
/// concession this design allows on the relay address instead.
/// </para>
/// <para>
/// The consequence is that the server cannot see a cycle, and it was once the argument for having no parent
/// at all: two offline clients can each re-parent A under B and B under A, a scalar merge accepts both, and
/// nothing server-side can refuse the pair. That is still true and is now handled rather than avoided —
/// every walk of the chain carries a visited set and stops at a repeat, so a cycle degrades to a group that
/// reads as a root. See <c>HostGroupSecret</c> on the client, which is the only thing that can read any of
/// this.
/// </para>
/// <para>
/// <b>No name, for the reason <see cref="VaultKnownHostKey"/> has no host column.</b> A group name is not
@@ -407,6 +417,81 @@ public sealed class VaultHostGroup : IVaultItem
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// A label that can be put on many hosts, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="VaultHostGroup"/>'s shape exactly — an envelope and its bookkeeping — and the reasoning for the
/// missing <c>name</c> column is the same argument at higher stakes. A group name describes how one user
/// files their machines; a tag name describes what the machines <em>are</em>, and one tag spans twenty hosts
/// by design. A column here would give the operator a labelled map of every estate on the server, to sort a
/// list nothing server-side draws.
/// </para>
/// <para>
/// <b>No membership, and no join table either.</b> Which hosts wear this tag is a set of ids inside each
/// host's payload. <c>SyncEntityType.HostTag</c> reserves a slot for the join and it stays reserved: the one
/// thing a join buys over a set on the host — two people tagging the same host without one of them losing —
/// is already bought by the client's per-key three-way merge, and a second item type is a second table, a
/// second migration and a second pass through every rule in this file for nothing.
/// </para>
/// </remarks>
public sealed class VaultTag : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a tag can be created offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted tag: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>Last modification timestamp.</summary>
public DateTimeOffset UpdatedAtUtc { get; set; }
/// <summary>
/// Soft-delete marker; a tombstone, so an offline client learns the tag went away.
/// </summary>
/// <remarks>
/// Deleting a tag leaves every host wearing it holding an id that resolves to nothing, and that is the
/// intended outcome: those hosts simply stop drawing that chip. Rewriting N host payloads inside one
/// delete would turn a single user action into N pushes, and each of those is a read-modify-write of a
/// set that can lose a merge — which would leave the vault holding a tag that is deleted and still worn.
/// </remarks>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// An S3-compatible bucket and the credentials that reach it, as ciphertext.
/// </summary>