using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// What was done to an item. public enum ActivityOperation { /// It was created. Created = 0, /// It was changed. Updated = 1, /// It was deleted. Deleted = 2, } /// /// One create, edit or delete of a keychain item, decrypted. /// /// /// /// holds names and never values. That is the rule the whole type is built /// around, and it is the same one ADR 0006 imposes on the server's own detail column: an audit log /// that recorded what a password used to be would be a plaintext credential store with a vault drawn around /// it. "Password" is what somebody needs to see; the old password is what nobody does. /// /// /// is a copy, taken at the time. Deleting the item is one of the three things /// this records, so a lookup would resolve to nothing in exactly the case the entry matters most. It is also /// what makes a rename readable — an entry saying "renamed 'old-db'" is useful, and one saying "renamed /// 'prod-db'" because that is what it is called now is not. /// /// public sealed record ActivityLogSecret : IVaultSecret { /// What kind of item this was about, as the sync contract names it. /// /// Stored as the wire enum's name rather than its number, so an entry written by a build that knows a /// kind this one does not still reads as something — an unknown name is shown as itself, where an /// unknown number would have to be shown as a number. /// public required string ItemKind { get; init; } /// The item, so an entry can be traced to what it was about. public required Guid ItemId { get; init; } /// What the item was called at the time. public required string ItemLabel { get; init; } /// What was done. public ActivityOperation Operation { get; init; } /// /// The names of the fields that changed, separated by ", ". Never their values. /// /// /// /// One string rather than a collection, and the choice is about equality. A plain /// on a record gets reference equality from the compiler-generated /// Equals, which is the trap exists to avoid — and a second type of that /// shape is a lot of machinery for a value that is written once and only ever displayed. The separator is /// unambiguous because these are C# property names, which cannot contain one. /// /// /// Empty for a create and for a delete, where "which fields" has no meaning — every field arrived, or all /// of them went. Empty is also the honest answer when an update's before and after could not be compared, /// which is why nothing reading this may take empty to mean "nothing changed". /// /// public string ChangedFields { get; init; } = string.Empty; /// When it happened. public required DateTimeOffset At { get; init; } /// Which machine it was done from, as that machine calls itself. public required string DeviceName { get; init; } /// Which account in this organisation did it. public Guid ActorUserId { get; init; } /// What this entry is called, derived from what it records. /// public string Label => $"{Operation} {ItemLabel}"; /// Whether this is storable, and why not if it is not. public bool TryValidate([NotNullWhen(false)] out string? reason) { if (string.IsNullOrWhiteSpace(ItemKind)) { reason = "An activity log entry needs the kind of item it was about."; return false; } if (ItemId == Guid.Empty) { reason = "An activity log entry needs the item it was about."; return false; } if (string.IsNullOrWhiteSpace(DeviceName)) { reason = "An activity log entry needs the machine it was done from."; return false; } // The label is deliberately not checked. An item somebody created and never named has an empty one, // and refusing to record that would mean the log's completeness depended on the user's tidiness. reason = null; return true; } }