Public Access
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:
@@ -65,8 +65,49 @@ public static class HostSecretCodec
|
||||
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
|
||||
public const int GroupIdSchemaVersion = 4;
|
||||
|
||||
/// <summary>
|
||||
/// The version that introduced inheritance: a null <see cref="HostSecret.Port"/> and
|
||||
/// <see cref="HostSecret.AsksForPassword"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The one version where "read-only on an older client" understates the cost.</b> Every field before
|
||||
/// this one is additive: an older build decodes the host, shows it, and refuses to save it. A null port
|
||||
/// is subtractive — the property is omitted, an older build's <c>int Port</c> reads 0, and
|
||||
/// <see cref="HostSecret.TryValidate"/> refuses the host outright. The item does not appear locked on
|
||||
/// that machine; it does not appear at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Accepted rather than worked around, because the two workarounds are worse. Writing 22 into every host
|
||||
/// makes inheritance a lie the moment a group's default changes, and it is what inheritance exists to
|
||||
/// stop. Keeping a second non-inheriting port field beside this one would mean two ports per host and a
|
||||
/// rule about which wins, on every screen and on the wire. What confines the cost is
|
||||
/// <see cref="SchemaVersionFor"/>: only a host that actually inherits its port is written here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="HostSecret.AsksForPassword"/> shares this version because it arrives in the same build and
|
||||
/// answers the same question — it is what a host says instead of naming a binding, once naming nothing
|
||||
/// has come to mean "ask the group".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const int PortInheritSchemaVersion = 5;
|
||||
|
||||
/// <summary>The version that introduced <see cref="HostSecret.TagIds"/>.</summary>
|
||||
/// <remarks>
|
||||
/// One past inheritance rather than sharing with it, because the two are independent: a host can wear
|
||||
/// tags without inheriting anything, and such a host stays decodable on a build that knows 5. That
|
||||
/// distinction is unobservable today — both shipped together — and it is stated anyway, because the rule
|
||||
/// this file follows is one version per field and the exception would have to be re-justified by whoever
|
||||
/// adds the seventh.
|
||||
/// </remarks>
|
||||
public const int TagIdsSchemaVersion = 6;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
public const int CurrentSchemaVersion = GroupIdSchemaVersion;
|
||||
/// <remarks>
|
||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||
/// maximum. A new field added below this line has to be named here too.
|
||||
/// </remarks>
|
||||
public const int CurrentSchemaVersion = TagIdsSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -99,6 +140,15 @@ public static class HostSecretCodec
|
||||
SshKeyId = host.SshKeyId,
|
||||
CredentialId = host.CredentialId,
|
||||
GroupId = host.GroupId,
|
||||
|
||||
// Null rather than false, so a host that never said anything about this encodes exactly as it
|
||||
// did before the field existed. See HostSecret.AsksForPassword.
|
||||
AsksForPassword = host.AsksForPassword is true ? true : null,
|
||||
|
||||
// Null rather than an empty array, for the same reason and with a wider blast radius: an empty
|
||||
// [] here would land in every host in every vault and make the first sync after the upgrade
|
||||
// read as though every one of them had changed.
|
||||
TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -156,6 +206,19 @@ public static class HostSecretCodec
|
||||
version = Math.Max(version, GroupIdSchemaVersion);
|
||||
}
|
||||
|
||||
// Both halves of inheritance, and this is the branch that loses an item rather than locking one if
|
||||
// it is forgotten — see PortInheritSchemaVersion. A host stamped at 4 with its port omitted is a
|
||||
// host an older client deletes from its own view.
|
||||
if (host.Port is null || host.AsksForPassword is true)
|
||||
{
|
||||
version = Math.Max(version, PortInheritSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.TagIds.Count > 0)
|
||||
{
|
||||
version = Math.Max(version, TagIdsSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -226,6 +289,12 @@ public static class HostSecretCodec
|
||||
SshKeyId = parsed.SshKeyId,
|
||||
CredentialId = parsed.CredentialId,
|
||||
GroupId = parsed.GroupId,
|
||||
|
||||
// False folds back to null: they say the same thing, and letting both onto the record would
|
||||
// give the merge two spellings of one state to report as a change nobody made.
|
||||
AsksForPassword = parsed.AsksForPassword is true ? true : null,
|
||||
|
||||
TagIds = TagSet.Create(parsed.TagIds ?? []),
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
@@ -255,7 +324,13 @@ internal sealed class HostPayloadDocument
|
||||
|
||||
public string? Hostname { get; set; }
|
||||
|
||||
public int Port { get; set; }
|
||||
/// <remarks>
|
||||
/// Nullable, which is what removes the key from the JSON for a host that inherits its port. It is also
|
||||
/// the one property here whose absence an older build cannot survive: it deserialises as
|
||||
/// <see langword="int"/> 0 there, which <see cref="HostSecret.TryValidate"/> refuses. See
|
||||
/// <see cref="HostSecretCodec.PortInheritSchemaVersion"/>.
|
||||
/// </remarks>
|
||||
public int? Port { get; set; }
|
||||
|
||||
public string? Username { get; set; }
|
||||
|
||||
@@ -284,6 +359,16 @@ internal sealed class HostPayloadDocument
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public Guid? GroupId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public bool? AsksForPassword { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Last, and it must stay last for the reason <see cref="SshKeyId"/> gives. Null when the host wears no
|
||||
/// tags, never <c>[]</c> — an empty array would be a new key in the JSON of every host in every vault,
|
||||
/// which the sync engine would read as every host having changed.
|
||||
/// </remarks>
|
||||
public Guid[]? TagIds { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
|
||||
Reference in New Issue
Block a user