# ADR: Accounts on a document store

Status: accepted, 2026-09-08. ASP.NET Core Identity, over one document per
account and one document per email address, in a container partitioned on the
account id. Parent: ADR: A second store on Cosmos DB, and what it costs. The
relational side is unchanged and is described in ADR: Accounts and per-user
bids and ADR: Identity and the session token, explained.

## In plain words

User accounts on the document database (Azure Cosmos DB) are stored as one document per account, plus a second, tiny document per email address that keeps each address unique. Signing in is two direct lookups by id (point reads), and the page lists what this design costs next to SQL Server.

What that is worth: a developer sees how to keep an address unique on a store with no unique index, and the organization gets sign-in at about two request units with every trade-off written down before a user finds it.

## Context

Identity's relational shape is seven tables: a user row plus claims, logins,
tokens, roles and two join tables, related by foreign keys and read with joins.
This application uses one of the seven. It registers an address with a password,
signs in by address, locks an account after five wrong guesses, and asks "who am
I" by id. It has no roles, issues no Identity tokens, holds no external logins
and stores no claims; its session is a JWT it signs itself (ADR: Accounts and
per-user bids).

There are no joins on the document store, and there is no unique index that
spans partitions. Those two facts are the whole design problem.

## The three options

**Keep accounts on SQL Server.** A two-store application, with the hard part
avoided. It is what a lot of real systems do and it would have been defensible.
Steve chose the full stack, and a comparison that kept accounts relational
would not measure sign-in, which is one of the three paths the parity bar is
earned on.

**`IdentityDbContext` on the EF Core Cosmos provider.** It compiles and it runs.
Seven entity types land in a document store as seven document shapes with a
discriminator; `FindByEmailAsync` becomes a query across every partition on a
property no index covers unless one is paid for on every write; and two
registrations of the same address a few milliseconds apart both succeed, because
the unique index that stops them on SQL Server (`UserNameIndex`, on the
normalized user name, which is the address here) does not exist on this
side. Nothing fails. The store just has two accounts with one
address, and the next sign-in finds whichever it finds.

**A custom `IUserStore` over one document per account.** Chosen.

## Decision

**One container, `users`, partition key `/id`.** Two document shapes in it.

```csharp
/// <summary>
/// One account, whole, in one document. Identity's relational shape is a user
/// row plus claims, logins, tokens and roles in separate tables; this application
/// uses none of those tables, so the document holds exactly the columns the user
/// row holds that it reads (ADR: Accounts on a document store).
/// </summary>
public sealed class UserDocument
{
    /// <summary>The account id, which is also the partition key (<c>id</c>).</summary>
    public string Id { get; set; } = "";

    /// <summary>The name the account signs in with, which is its email address (<c>user_name</c>).</summary>
    public string? UserName { get; set; }

    /// <summary>The user name as Identity normalizes it for lookups (<c>normalized_user_name</c>).</summary>
    public string? NormalizedUserName { get; set; }

    /// <summary>The account's email address (<c>email</c>).</summary>
    public string? Email { get; set; }

    /// <summary>The email address as Identity normalizes it for lookups (<c>normalized_email</c>).</summary>
    public string? NormalizedEmail { get; set; }

    /// <summary>Whether the address has been confirmed (<c>email_confirmed</c>).</summary>
    public bool EmailConfirmed { get; set; }

    /// <summary>The password, as Identity's hasher wrote it; never the password itself (<c>password_hash</c>).</summary>
    public string? PasswordHash { get; set; }

    /// <summary>Identity's stamp, changed whenever the credentials change so older sign-ins stop working (<c>security_stamp</c>).</summary>
    public string? SecurityStamp { get; set; }

    /// <summary>When a lockout ends, or null when the account is not locked out (<c>lockout_end</c>).</summary>
    public DateTimeOffset? LockoutEnd { get; set; }

    /// <summary>Whether failed guesses can lock this account out (<c>lockout_enabled</c>).</summary>
    public bool LockoutEnabled { get; set; }

    /// <summary>How many wrong passwords in a row since the last good one (<c>access_failed_count</c>).</summary>
    public int AccessFailedCount { get; set; }

    /// <summary>When the account was created, in milliseconds since the epoch, UTC (<c>created_at_ms</c>).</summary>
    public long CreatedAtMs { get; set; }

    /// <summary>The document's version, kept by the store and used as the concurrency stamp (<c>_etag</c>).</summary>
    [JsonPropertyName("_etag")]
    public string? ETag { get; set; }
}

/// <summary>
/// The claim on an email address. A store with no unique index across
/// partitions cannot promise two accounts will not share an address; a document
/// whose id is the address can, because a second create of the same id is
/// refused with 409. Registering writes this first and the account second
/// (ADR: Accounts on a document store).
/// </summary>
public sealed class EmailClaimDocument
{
    /// <summary>What every claim's id starts with, so a claim can never share an id with an account.</summary>
    public const string Prefix = "email:";

    /// <summary>The prefix and the normalized address, which is also the partition key (<c>id</c>).</summary>
    public string Id { get; set; } = "";

    /// <summary>The account that holds the address (<c>user_id</c>).</summary>
    public string UserId { get; set; } = "";

    /// <summary>
    /// When the store last wrote it, in seconds since the epoch, set by the
    /// service and never by this code: null on the way in, so nothing is
    /// written, and the service's own value on the way back. The account
    /// store reads it to tell an orphaned claim from one whose account is a
    /// moment away (ADR: Accounts on a document store, addendum).
    /// </summary>
    [JsonPropertyName("_ts")]
    public long? Timestamp { get; set; }

    /// <summary>The claim id for a normalized address.</summary>
    public static string IdFor(string normalizedEmail) => Prefix + normalizedEmail;
}
```
*Live from [`api/TheYard.Infrastructure.Cosmos/Documents.Users.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure.Cosmos/Documents.Users.cs#L9-L88), region `documents`, read from this build at 1d6b50b.*

**The account is one document**, id and partition key both the account id, so
"who am I" is one point read, about one request unit. It carries exactly the
columns of the user row this application reads: the address and its normalized
form, the password hash, the security stamp, the lockout fields and the count
of failed guesses, and the one field this application added to Identity's
user, when the account was created.

**The address is a second, tiny document**, id `email:<normalized address>`,
carrying the account id. It exists to be unique. A store that cannot declare a
unique constraint across partitions can still refuse to create two documents
with one id in one partition, and a document whose id is the address and whose
partition is its own id is exactly that: the second registration of an address
is a 409 from the store, not a race that both sides win.

**Registering writes the claim first and the account second.**

```csharp
/// <summary>
/// The claim first, the account second. A store with no unique index across
/// partitions cannot refuse a second account on the same address; a create
/// of a document whose id is the address can, with 409, and that refusal is
/// what makes the address unique. If the account write then fails, the
/// claim is removed again, so a failure leaves nothing behind. A process
/// that dies between the two writes leaves a claim with no account, which
/// is the honest cost of the shape and is written down in the record.
/// </summary>
public async Task<IdentityResult> CreateAsync(YardUser user, CancellationToken cancellationToken)
{
    string normalizedEmail = user.NormalizedEmail ?? user.NormalizedUserName
        ?? throw new InvalidOperationException("an account needs a normalized email address before it is stored");
    var claim = new EmailClaimDocument { Id = EmailClaimDocument.IdFor(normalizedEmail), UserId = user.Id };
    try
    {
        await store.CreateAsync(store.Users, claim, claim.Id, ClaimPartition, "CreateItem (email claim)");
    }
    catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
    {
        return IdentityResult.Failed(Errors.DuplicateEmail(user.Email ?? ""));
    }

    try
    {
        var response = await store.CreateAsync(store.Users, ToDocument(user), user.Id, Partition, "CreateItem (account)");
        user.ConcurrencyStamp = response.ETag;
        return IdentityResult.Success;
    }
    catch (CosmosException)
    {
        await TryDeleteClaimAsync(claim.Id);
        throw;
    }
}
```
*Live from [`api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs#L32-L66), region `create`, read from this build at 1d6b50b.*

If the claim is refused the registration fails with Identity's own duplicate
email error, and `Accounts.Explain` turns that into the same sentence the
relational side shows. If the account write fails after the claim succeeded, the
claim is deleted again, so a failed registration leaves nothing behind.

**Sign-in is two point reads.** The claim by address, then the account by the id
the claim carries. About two request units and two round trips, in region, and
neither of them a query:

```csharp
public async Task<YardUser?> FindByIdAsync(string userId, CancellationToken cancellationToken)
{
    var document = await store.ReadAsync<UserDocument>(store.Users, userId, userId, Partition);
    return document is null ? null : ToUser(document);
}

/// <summary>
/// The address first, then the account: two point reads, about a request
/// unit each, against the one cross-partition query the relational shape
/// would run. This application registers the address as the user name, so
/// a lookup by normalized name is the same lookup.
/// </summary>
public async Task<YardUser?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
{
    string claimId = EmailClaimDocument.IdFor(normalizedEmail);
    var claim = await store.ReadAsync<EmailClaimDocument>(store.Users, claimId, claimId, ClaimPartition);
    if (claim is null)
    {
        return null;
    }
    var user = await FindByIdAsync(claim.UserId, cancellationToken);
    if (user is null && IsOrphan(claim))
    {
        // A claim with no account behind it is a registration that died
        // between its two writes, and until this line existed the address
        // was refused forever. Removed only once it is older than the
        // longest a registration takes, so a claim written a moment ago by
        // a registration still in flight is left alone (the second review).
        await TryDeleteClaimAsync(claimId);
    }
    return user;
}

/// <summary>How long a claim may stand without its account before a lookup treats it as abandoned.</summary>
public TimeSpan OrphanAge { get; } = orphanAge ?? TimeSpan.FromMinutes(1);

private bool IsOrphan(EmailClaimDocument claim) =>
    claim.Timestamp is { } written
    && DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(written) >= OrphanAge;

public Task<YardUser?> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) =>
    FindByEmailAsync(normalizedUserName, cancellationToken);
```
*Live from [`api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs#L119-L160), region `find`, read from this build at 1d6b50b.*

This application registers the address as the user name, so Identity's lookup by
normalized user name is the same lookup as the one by normalized email, and the
store treats them as one.

**A failed guess is one replace, carrying the etag.** Identity keeps a
concurrency stamp on the user and rotates it on every update; on this side the
stamp is the document's own `_etag`, read with the document and sent back as
`If-Match` on the replace. A stale one is refused with 412, which the store
reports as Identity's concurrency failure, exactly as a row that moved would be
reported on SQL Server:

```csharp
/// <summary>
/// A replace that carries the etag the account was read with. Identity keeps
/// that in ConcurrencyStamp, which on the relational side is a GUID the
/// framework rotates and here is the document's own etag; a stale one is
/// refused with 412, which Identity reports as a concurrency failure
/// exactly as it would a row that moved under it.
/// </summary>
public async Task<IdentityResult> UpdateAsync(YardUser user, CancellationToken cancellationToken)
{
    try
    {
        var response = await store.ReplaceAsync(store.Users, ToDocument(user), user.Id, user.Id, user.ConcurrencyStamp, Partition);
        user.ConcurrencyStamp = response.ETag;
        return IdentityResult.Success;
    }
    catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
    {
        return IdentityResult.Failed(Errors.ConcurrencyFailure());
    }
}
```
*Live from [`api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs#L70-L89), region `update`, read from this build at 1d6b50b.*

A successful sign-in with no failed guesses on the account writes nothing at
all, because `UserManager.ResetAccessFailedCountAsync` returns before touching
the store when the count is already zero.

**The store implements five interfaces and no more**: the base, password, email,
lockout and security stamp. Roles, claims, logins and tokens are not implemented,
which means a call to them fails loudly at registration time rather than
silently at runtime, and which is the honest description of what this
application uses.

## Where the document model costs something

Said plainly, because the comparison record keeps this list.

- **Uniqueness is a document you write, not a constraint you declare.** The
  claim document is a line of code doing what one line of DDL does on the other
  side, and a reviewer has to know it is there.
- **A process that dies between the two writes leaves a claim with no account.**
  Until it is cleaned up, that address reads as taken. The relational side has
  no such window because the row and its unique index are one write.
- **There is no cascade.** Deleting an account on SQL Server takes its bids with
  it, because the foreign key says so. Here a bid is a document in another
  container partitioned on the buyer, and deleting the account leaves them. This
  application has no delete-account endpoint, so nothing reaches this today; the
  record says it rather than a visitor finding it.
- **Nothing enforces the shape.** A document with a missing field is a document
  with a missing field. The conformance tests on the relational side compare
  DDL to model; here the round-trip tests are what hold the shape.

## What the tests hold

Against the real account, on the runner, as the signed-in principal, through a
real `UserManager<YardUser>` rather than the store's methods directly, so what
is tested is what the endpoints call:

```csharp
private static ServiceProvider Identity(CosmosStore store)
{
    var services = new ServiceCollection();
    services.AddLogging();
    services.AddSingleton(store);
    services.AddIdentityCore<YardUser>(options =>
    {
        options.Password.RequiredLength = 8;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequireUppercase = false;
        options.Password.RequireDigit = false;
        options.User.RequireUniqueEmail = true;
        options.Lockout.MaxFailedAccessAttempts = 5;
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
        options.Lockout.AllowedForNewUsers = true;
    }).AddUserStore<CosmosUserStore>();
    return services.BuildServiceProvider();
}

[Fact]
public async Task An_account_is_one_document_and_one_claim_and_the_second_claim_on_an_address_is_refused()
{
    var (store, log) = CosmosLive.Open();
    using var provider = Identity(store);
    using var scope = provider.CreateScope();
    var users = scope.ServiceProvider.GetRequiredService<UserManager<YardUser>>();
    string email = $"leak-canary-{Guid.NewGuid():N}@example.com";

    var created = await users.CreateAsync(new YardUser { UserName = email, Email = email, CreatedAtMs = 1 }, "correct horse");
    Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(e => e.Description)));

    var again = await users.CreateAsync(new YardUser { UserName = email, Email = email, CreatedAtMs = 2 }, "correct horse");
    Assert.False(again.Succeeded);
    Assert.Contains(again.Errors, e => e.Code.Contains("Duplicate", StringComparison.Ordinal));

    var found = await users.FindByEmailAsync(email);
    Assert.NotNull(found);
    Assert.True(await users.CheckPasswordAsync(found, "correct horse"));
    Assert.False(await users.CheckPasswordAsync(found, "wrong"));

    // A failed guess moves the count, through a replace that carries the etag.
    await users.AccessFailedAsync(found);
    var after = await users.FindByIdAsync(found.Id);
    Assert.Equal(1, after!.AccessFailedCount);
    await users.ResetAccessFailedCountAsync(after);

    // "Who am I" is one point read; sign-in by address is two.
    var operations = log.Snapshot();
    Assert.Contains(operations, o => o.Container == "users" && o.Text == "CreateItem (email claim)");
    Assert.Contains(operations, o => o.Container == "users" && o.Text == "CreateItem (account)");
    Assert.Contains(operations, o => o.Container == "users" && o.Kind == StoreOperationKind.PointWrite && o.Text == "ReplaceItem (If-Match)");
    Assert.DoesNotContain(operations, o => o.Container == "users" && o.Kind == StoreOperationKind.Query);

    // The no-values rule, on the store that has the address as a document id.
    foreach (var operation in operations)
    {
        Assert.DoesNotContain(email, operation.Text, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain(email, operation.Partition, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("leak-canary", operation.Text, StringComparison.OrdinalIgnoreCase);
    }

    var deleted = await users.DeleteAsync(after);
    Assert.True(deleted.Succeeded);
    Assert.Null(await users.FindByEmailAsync(email));
    // The address is free again: the claim went with the account.
    var third = await users.CreateAsync(new YardUser { UserName = email, Email = email, CreatedAtMs = 3 }, "correct horse");
    Assert.True(third.Succeeded);
    await users.DeleteAsync((await users.FindByEmailAsync(email))!);
}
```
*Live from [`api/TheYard.Tests/CosmosStoreTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/CosmosStoreTests.cs#L146-L214), region `accounts`, read from this build at 1d6b50b.*

Registration creates two documents, the second registration of the address is
refused with a duplicate error, sign-in finds the account and checks the
password, a failed guess moves the count through a replace, the store ran no
query at all, no operation in the store log carries the address, and deleting
the account frees the address. The suite also runs whole with every application
booted on the document store, so the accounts tests written for SQL Server run
against this store unchanged (ADR: A second store on Cosmos DB, and what it
costs).

## Consequences

- Sign-in and "who am I" are point reads, which is the cheapest thing the store
  does, and the comparison card can put their charge beside their milliseconds.
- Identity's seven tables are four fewer document shapes than the provider
  would have made, and the two that exist are readable beside the API's own JSON.
- The address is unique by construction and the construction is visible.
- The costs above are real and are written here rather than found later.

## Where it sits

The accounts code sits in Infrastructure.Cosmos, where `CosmosUserStore` and the user and email-claim documents in `Documents.Users.cs` live, and it borrows `YardUser` from Infrastructure, which is the one reason that project references Infrastructure at all; the host's composition root hands it to Identity in place of the Entity Framework store. Interface segregation, small interfaces shaped to what a caller needs, is the principle at work, because Identity splits its store into narrow interfaces and this one implements only the five the application calls, leaving roles, claims, logins and tokens unimplemented. The cost is that email uniqueness is a document the code writes, and a crash between the two writes can leave a claim with no account until a later lookup clears it, with no cascade to take a user's bids when the account goes. An application that needed roles or a delete-account feature would be better served keeping Identity on SQL Server.

## Files

- [`api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/CosmosUserStore.cs): the five interfaces, the claim, the etag.
- [`api/TheYard.Infrastructure.Cosmos/Documents.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/Documents.cs): the two document shapes.
- [`infra/cosmos/users.json`](https://github.com/SteveStout/TheYard/blob/main/infra/cosmos/users.json): the container, partitioned on the id, indexing nothing.
- [`api/TheYard.Api/Composition/AuthRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/AuthRegistration.cs): where Identity is given this store instead of the Entity Framework one (region user-store-per-request).
- [`api/TheYard.Tests/CosmosStoreTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/CosmosStoreTests.cs): the proof, against the real account.
- [`docs/decisions/ADR-037-accounts.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-037-accounts.md): the relational side, and the endpoints both sides serve.
