namespace DodoSSH.Domain; /// /// An SSH host. /// /// /// /// The only vault item type in M1. Everything sensitive — username, notes, jump chain, SSH /// options — lives inside . There is deliberately no plaintext label: /// access-control administration happens in the client, which can decrypt names, so the server /// never needs a searchable title. /// /// /// and are the one deliberate plaintext concession, and /// only when is set. The relay must resolve its target server-side or /// it becomes an authenticated open TCP proxy into the operator's own network. A database CHECK /// constraint enforces the pairing so it cannot drift. See ADR 0004. /// /// /// There is no group column, and there used to be one. A group_id was reserved here from the /// first migration and never written by any shipped client; it was dropped when groups actually landed, /// because membership belongs inside . The test ADR 0004 sets for a plaintext /// concession is that the server cannot function without the value, and nothing on the server reads /// a group — the client decrypts its own vault to draw the sidebar. What the column would have handed over /// is a clustering of the estate: which machines this user files together, for free, in the clear. See /// . /// /// public sealed class SshHost : IVaultItem { /// Primary key. UUIDv7, generated by the client so items can be created offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted item: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// /// Reserved for per-item content keys wrapped to individual users, which is what will make /// per-item access control cryptographic rather than server-enforced. Present from the first /// migration so that lands without a migration; see docs/crypto.md §3. /// public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// Whether this host may be dialled through the server relay. public bool RelayEnabled { get; set; } /// Target hostname. Permitted only when is set. public string? Hostname { get; set; } /// Target port. Permitted only when is set. public int? Port { get; set; } /// /// Client-visible, monotonic item version. Used for optimistic concurrency on push, and /// deliberately distinct from the internal xmin guard, which is never exposed because /// it is not stable across VACUUM FREEZE. /// public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// /// Soft-delete marker. Deletes are tombstones: a client that has been offline must be able to /// learn an item went away, and a vanished row is indistinguishable from one never seen. /// public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// An SSH key pair, held as ciphertext. /// /// /// /// The private key, its passphrase, its label and its comment are all inside . This /// row is the reason the vault is worth having — a key that syncs between a user's machines and can later be /// shared with a teammate — and it is also the row that would hurt most if the server could read it, so /// there is deliberately not one plaintext column of substance. /// /// /// Notably absent: the relay trio. A key is not something the server dials, so the plaintext concession /// ADR 0004 makes for has no analogue here and no reason to exist. That is the whole /// argument for a separate table rather than one wide item table: the columns a host needs are columns a key /// must never have, and a shared table could only make them nullable and trust the code. /// /// /// is the single exception, and it stays null until something needs it. /// The contract reserved the slot for showing which key a host is configured to use without decrypting /// every key first; write it only when that feature lands, and never derive anything security-relevant from /// it, because a fingerprint the server stores is a fingerprint the server chose. /// /// public sealed class VaultSshKey : IVaultItem { /// Primary key. UUIDv7, generated by the client so keys can be created offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted key material: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// /// OpenSSH-style SHA256:base64 fingerprint of the public half, when a client publishes it. /// /// /// Plaintext, and therefore opt-in and currently unused. A public key fingerprint is not a secret, but /// it is an identifier that links a vault to a machine's authorized_keys, so it is stored only /// when a feature needs it rather than because it is harmless. /// public string? PublicKeyFingerprint { get; set; } /// Client-visible, monotonic item version, used for expectedVersion checks. public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// Soft-delete marker; a tombstone, so an offline client learns the key went away. public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// A stored username and password, as ciphertext. /// /// /// /// Its own table for the same reason is: the columns a host needs are columns a /// credential must never have. There is no relay trio here, and unlike a key there is not even a fingerprint /// — nothing about a password is safe to hold in the clear, not its length, not a hash, not a hint. So this /// row is an opaque envelope and its bookkeeping, and that is the whole design. /// /// /// The server cannot enforce anything about the contents, and should not pretend to. Whether a /// credential has a username, whether its password is empty, whether it is still valid — all of that is /// inside the payload and belongs to the client. The one thing this row asserts is that the ciphertext /// belongs to a vault and carries a version, which is what the write path needs to order changes. /// /// public sealed class VaultCredential : IVaultItem { /// Primary key. UUIDv7, generated by the client so credentials can be created offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted credential: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// Client-visible, monotonic item version, used for expectedVersion checks. public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// Soft-delete marker; a tombstone, so an offline client learns the credential went away. public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// One SSH host key a user has decided to trust, as ciphertext. /// /// /// /// The host, the port, the key algorithm and the fingerprint are all inside , and this /// is the item type where that is least obvious and most deliberate. None of those four values is /// confidential on its own — an operator publishes the fingerprint so it can be checked, and a /// relay-enabled host's address is already in a plaintext column next door. Together and across a user's /// whole vault they are something else: which machines that person reaches, and when they first reached /// them. The server has no use for any of it, so it gets none of it, and there is no column here to put it /// in even by mistake. /// /// /// Why the server holds these at all, given it cannot read them. Trust-on-first-use is only as good /// as its memory. Kept locally, a pin dies with the machine and the user is asked to approve the same host /// on every device — training them to approve without looking, which is the one habit that makes the /// warning worthless. Kept here, trust follows the user, and because the payload is sealed the server /// cannot drop a pin to force a fresh first-use decision without the client noticing the item is gone. /// /// /// Otherwise this is 's shape exactly: an opaque envelope and the bookkeeping /// the write path needs to order changes. Its own table for the same reason — the columns a host needs are /// columns this must never have. /// /// public sealed class VaultKnownHostKey : IVaultItem { /// Primary key. UUIDv7, generated by the client so a pin can be recorded offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted pin: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// Client-visible, monotonic item version, used for expectedVersion checks. public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// /// Soft-delete marker; a tombstone, so an offline client learns the pin was withdrawn. /// /// /// A tombstone matters more here than for the other types. Withdrawing trust is the deliberate act a /// user performs when a server has legitimately been rebuilt, and a pin that simply vanished from one /// machine's view would come back on the next pull — leaving the rebuilt host permanently unreachable /// from the machine that had the old key. /// public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// A folder hosts can be filed under, as ciphertext. /// /// /// /// 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 parent_id and no name column, and both absences /// are deliberate. /// /// /// No parent column, although groups do nest. The pointer exists; it lives in the payload, and the /// push path refuses a plaintext ParentId 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 group_id on a host would have been, and ADR 0004 spends the one plaintext /// concession this design allows on the relay address instead. /// /// /// 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 HostGroupSecret on the client, which is the only thing that can read any of /// this. /// /// /// No name, for the reason has no host column. A group name is not /// confidential the way a password is, but the set of names one person files their machines under is a /// description of the estate — "customer-a", "pci", "on-call" — and the server has no use for any of it. It /// sorts nothing; the client decrypts its own vault to draw a list. /// /// /// Membership lives on the host, not here. The alternative — a member id list in this payload — would /// make adding one host to a group a write to the group, so two clients adding two different hosts at once /// would collide on one item. ThreeWayMerge has no set merge, so that collision would resolve by one /// side winning and the other host quietly leaving the group. One pointer per host makes each of those two /// operations a write to a different item, which cannot collide at all. /// /// public sealed class VaultHostGroup : IVaultItem { /// Primary key. UUIDv7, generated by the client so a group can be created offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted group: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// Client-visible, monotonic item version, used for expectedVersion checks. public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// /// Soft-delete marker; a tombstone, so an offline client learns the group went away. /// /// /// Deleting a group leaves every host that named it holding an id that resolves to nothing, and that is /// the intended outcome rather than an oversight: those hosts fall back to the ungrouped heading. The /// alternative is rewriting N host payloads inside one delete, which turns a single user action into N /// pushes, N outbox rows and N chances to merge against a change nobody made. /// public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// A label that can be put on many hosts, as ciphertext. /// /// /// /// 's shape exactly — an envelope and its bookkeeping — and the reasoning for the /// missing name 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 are, 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. /// /// /// No membership, and no join table either. Which hosts wear this tag is a set of ids inside each /// host's payload. SyncEntityType.HostTag 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. /// /// public sealed class VaultTag : IVaultItem { /// Primary key. UUIDv7, generated by the client so a tag can be created offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted tag: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// Client-visible, monotonic item version, used for expectedVersion checks. public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// /// Soft-delete marker; a tombstone, so an offline client learns the tag went away. /// /// /// 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. /// public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// An S3-compatible bucket and the credentials that reach it, as ciphertext. /// /// /// /// 's shape, and it holds the same class of thing: a secret access key is a /// password by another name, and everything around it — the endpoint, the region, the bucket — describes /// somewhere the user keeps data. There is no plaintext column and there is no argument for one; the relay /// does not dial a bucket, so ADR 0004's single concession has no analogue here. /// /// /// The endpoint is inside the payload even though it is often s3.amazonaws.com. For everybody /// self-hosting MinIO or Ceph it is an address on their own network, which is precisely the thing the host /// table only stores in the clear when the relay cannot work without it. Nothing on the server dials this. /// /// public sealed class VaultObjectStore : IVaultItem { /// Primary key. UUIDv7, generated by the client so a bucket can be added offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted bucket and its keys: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// Client-visible, monotonic item version, used for expectedVersion checks. public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// Soft-delete marker; a tombstone, so an offline client learns the bucket went away. public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// One connection that was made, as ciphertext. /// /// /// /// Written once, at close, and never updated. That single decision is what makes a synced log /// tractable: an entry that is created and then left alone needs no three-way merge, produces exactly one /// outbox row, and cannot collide with the unique index that allows one outbox row per item. A connection /// that is still open is shown from the workspace's own in-memory state instead, because that is where the /// truth about a running shell actually lives. /// /// /// Synced, and that is a deliberate trade rather than the obvious choice. A log kept on the machine /// that produced it cannot be read by an administrator, cannot survive a reinstall, and cannot be checked /// against anything. Keeping it here buys team auditing; what it costs is that the operator learns a user's /// connection rate and timing from row counts and updated_at, even though every field inside /// is sealed. ADR 0001 already concedes it cannot hide that class of metadata; this widens it, and says so. /// /// /// The shape is 's: an envelope and its bookkeeping, no plaintext column of any /// kind. Which machines somebody reaches and when is the single most revealing thing this schema could hold /// in the clear, which is exactly why it holds none of it. /// /// public sealed class VaultConnectionLogEntry : IVaultItem { /// Primary key. UUIDv7, generated by the client, so an entry can be written offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted entry: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// /// Client-visible, monotonic item version. /// /// /// Always 1 in practice, because nothing updates a log entry. The column stays because the shared write /// path needs it, and a kind that opted out of the version check would be a second write path. /// public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// /// Soft-delete marker; a tombstone, so an offline client learns the entry was pruned. /// /// /// Retention is the only thing that deletes one of these, and it is a real tombstone that pushes — which /// is why it is rate-limited rather than run on a timer. A prune that did not sync would delete the same /// thousand entries again on every machine, for ever. /// public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// One create, edit or delete of a vault item, as ciphertext. /// /// /// /// 's shape and its write-once rule, for a different history: what /// changed in the keychain rather than what was connected to. The payload records the names of the /// fields that changed and never their values, which is the same rule ADR 0006 imposes on the server's own /// detail column. A log that recorded an old password would be a plaintext credential store with no /// vault around it. /// /// /// Note the deliberate asymmetry with the outbox, because it reads as a discrepancy otherwise: the outbox /// coalesces two edits of one item into a single pending row, and this log does not — two edits are /// two lines. The outbox describes what still has to be sent; this describes what somebody did. /// /// public sealed class VaultActivityLogEntry : IVaultItem { /// Primary key. UUIDv7, generated by the client, so an entry can be written offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted entry: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } } /// /// A saved command, as ciphertext. /// /// /// /// 's shape exactly, and for a stronger reason than symmetry. A snippet is a /// command somebody runs on their infrastructure — systemctl restart against a named unit, a /// psql line naming a database — and read as a set it describes what the estate is made of at least /// as precisely as a list of hostnames does. There is nothing here the server could sort by that is worth /// what holding it would cost, so there is no plaintext column to put it in even by mistake. /// /// /// Whether the snippet runs on its own or merely gets typed into the terminal is inside the payload too, and /// that is worth stating: it is the field the whole feature's safety rests on, so it must be one that /// travels sealed and merges with everything else it belongs to, rather than a flag the server could see or /// change. /// /// public sealed class VaultSnippet : IVaultItem { /// Primary key. UUIDv7, generated by the client so a snippet can be written offline. public Guid Id { get; set; } /// Owning vault. public Guid VaultId { get; set; } /// Owning vault. public Vault? Vault { get; set; } /// The encrypted snippet: a DSH1 envelope. Opaque to the server. public byte[] Payload { get; set; } = []; /// The item's data key, wrapped under the vault key. Opaque. public byte[]? DataKeyWrap { get; set; } /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. public Guid? ContentKeyId { get; set; } /// Vault key generation this payload was encrypted under. public int KeyGeneration { get; set; } /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. public short PayloadAadVersion { get; set; } /// Client-visible, monotonic item version, used for expectedVersion checks. public int Version { get; set; } /// Latest change-log sequence touching this row, so a delta pull can join directly. public long ChangeSequence { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Last modification timestamp. public DateTimeOffset UpdatedAtUtc { get; set; } /// Soft-delete marker; a tombstone, so an offline client learns the snippet went away. public DateTimeOffset? DeletedAtUtc { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Who last modified it. public Guid UpdatedByUserId { get; set; } }