TailSpend Docs

Database Schema

TailSpend uses Amazon DynamoDB with three tables, all on-demand billing. The data model is shaped around the access patterns of the HTTP API (see api.md) and the system architecture in architecture.md.

The three tables are: Users, Identities, and GroupData — a single-table layout that holds every per-group entity (group head, invite settings, join requests, participants, expenses, comments, and recurring-expense templates), all sharing the GroupId partition.

Table names follow the pattern: tailspend-{stage}-{table} (e.g. tailspend-prod-groups).

Why DynamoDB

Tables at a glance

The diagram is the logical model. Physically, four DynamoDB tables back the domain (plus the rate-limit counter table):

erDiagram
  USERS ||--o{ IDENTITIES : "has"
  USERS ||--o{ PARTICIPANTS : "claimed as"
  USERS ||--o{ JOINREQUESTS : "applicant of"
  GROUPS ||--|| INVITES : "has one"
  GROUPS ||--o{ PARTICIPANTS : "has participants"
  GROUPS ||--o{ JOINREQUESTS : "receives"
  GROUPS ||--o{ EXPENSES : "contains"
  PARTICIPANTS ||--o{ JOINREQUESTS : "takeover target"
  EXPENSES ||--o{ COMMENTS : "has"
  USERS {
    string UserId PK
  }
  IDENTITIES {
    string IdentityKey PK
    string UserId
  }
  GROUPS {
    string GroupId PK
    string SK PK
  }
  INVITES {
    string GroupId PK
    string SK PK
    bool Enabled
  }
  JOINREQUESTS {
    string GroupId PK
    string SK PK
    string RequestId
    string ApplicantUserId
    string Status
  }
  PARTICIPANTS {
    string GroupId PK
    string ParticipantId PK
    string UserId
  }
  EXPENSES {
    string GroupId PK
    string SK PK
    string ExpenseId
  }
  COMMENTS {
    string GroupId PK
    string SK PK
    string CommentId
  }

Each entity block lists only its keys and the foreign-key references that explain the relationships above it. Full attribute lists, types, and access patterns are documented in the per-section listings below.

Every per-group entity lives in the same physical DynamoDB table (GroupData) and is distinguished by the sort-key prefix and the EntityType attribute. They share a partition (GroupId) so a single Query can return a group and all of its rows, and a single TransactWriteItems can atomically touch any combination of them.

Conventions

These conventions apply uniformly to every key, attribute, and stored value in the schema. Violating them will silently break access patterns or GSI sort order.

Users Table

Profile data for authenticated users. Rows are written by the Cognito provisioning Lambda (wired to both the post-confirmation and post-authentication triggers) the first time a user signs in (alongside the identity rows in the Identities Table).

Attribute Type Key Description
UserId String PK UUID v4 minted by the post-authentication Lambda.
DisplayName String Human-readable name shown in clients. Initially seeded from the IdP profile, editable thereafter.
IsTestAccount Boolean Present only on integration-test accounts (see Test accounts). Absence means a real user.
CreatedAt String ISO 8601 timestamp, UTC (Z).
UpdatedAt String ISO 8601 timestamp, UTC (Z).

UserId is internal and stable across identity providers. It is referenced by Participants.UserId (set on USER participants) and by Owner and CreatedBy references in domain entities.

Test accounts

The api-integration-tests suite provisions throwaway Cognito users per run and deletes them in teardown, but teardown is best-effort — a cancelled CI job or a crashed worker leaves the account behind, and those leftovers used to sit in the admin user list alongside real people (issue #451).

So test accounts are marked instead of chased. isTestAccountEmail (in @repo/api-schema) matches the @integ.tailspend.kinesis.studio domain — one TailSpend owns and points at nothing, so no real user can hold an address under it — and:

GET /admin/users then excludes them unless includeTestAccounts=true. The attribute is omitted entirely for real users, so its presence alone means "test".

Access Patterns

Pattern Operation Key Condition
Read a user profile GetItem UserId = <uid>
Update display name UpdateItem UserId = <uid>

API usage rows

Daily API usage aggregates (issue #632) share the user's partition. See core-api.md for semantics.

SK Written by Attributes
USAGE#APP#DAY#<yyyy-mm-dd> app core function, one UpdateItem per tracked request RequestCount, SuccessCount, RedirectCount, ClientErrorCount, ServerErrorCount, RateLimitedCount, AdmittedUnits, ExpiresAt (TTL: end of day + 90 days)
USAGE#DEVELOPER#DAY#<yyyy-mm-dd>#TOKEN#<tokenId> developer core function, one UpdateItem per tracked request same

Read with UserId = <uid> AND SK BETWEEN <prefix><from> AND <prefix><to>. The rows never set RecoveryState/RecoveryDueAt, so they stay out of ScanRecoveryIndex; profile, balance and ledger reads are keyed or begins_with on their own prefixes and never see them.

DeveloperTokens Table

Personal access tokens and developer expense-create idempotency records (issue #632). Keys: TokenId (PK), SK.

SK Attributes
TOKEN UserId (owner), Name, Scopes, GroupIds, SecretHash (hex SHA-256 of the secret — the secret itself is never stored), CreatedAt, TokenExpiresAt, RevokedAt?, LastUsedAt? (refreshed at most hourly), ReplacesTokenId?
SLOTS (with TokenId = OWNER#<userId>) Active (map of the owner's token ids → expiry), Version. Read strongly consistent and rewritten under a Version condition in the same transaction as every mint/rotation, so the 10-active-token limit is atomic. Never carries UserId, so it stays out of the index.
IDEMPOTENCY#<sha256(key)> Operation, GroupId, RequestHash, ResourceId (the expense id every retry targets), State (PENDING/COMPLETE), CreatedAt, ExpiresAt (TTL, 24 h)

Token expiry and revocation are enforced in code; token rows carry no TTL attribute, so revoked and expired tokens stay listed for their owner's usage history until account deletion removes them.

GSI OwnerTokensIndex (sparse): UserId / CreatedAt, only on TOKEN rows; projects every attribute except SecretHash. Lists one owner's tokens.

Pattern Operation
Authenticate a token GetItem (TokenId, "TOKEN"), strongly consistent
List my tokens Query OwnerTokensIndex UserId = <uid>
Revoke UpdateItem conditioned on UserId = <owner>
Rotate TransactWriteItems: Put new + conditional revoke of old
Mint (atomic limit) Consistent GetItem on the slot row + consistent BatchGetItem of its tokens, then TransactWriteItems: Put token + versioned slot update
Claim an idempotency key PutItem attribute_not_exists(SK), then consistent GetItem on conflict

People Table

Discovery and social state (issue #643, see people.md). Keys: PK, SK.

PK SK Attributes
HANDLE#<handle> CLAIM UserId — the unique, lowercase handle's owner
USER#<u> DIR#<token> DirKey (first two characters of the token), DirSort (<token>#<u>), UserId, DisplayName, Handle? — present only while u is discoverable
USER#<a> REL#<b> OtherUserId, State (FRIEND / OUTGOING / INCOMING), UpdatedAt — mirrored on both sides in one transaction
USER#<a> BLOCK#<b> OtherUserId, CreatedAt — a blocked b
USER#<invitee> INVITE#<groupId> GroupId, InviteeUserId, InvitedBy, Role, CreatedAt, InviteExpiresAt, ExpiresAt (TTL: a week past expiry)

GSI DirectoryIndex (sparse): DirKey / DirSort, INCLUDE UserId, DisplayName, Handle. Name/handle prefix search: DirKey = q[0:2] AND begins_with(DirSort, q).

GSI GroupInvitesIndex (sparse): GroupId / InviteeUserId, ALL. An owner's list of a group's pending invitations.

The handle and the search opt-in are mirrored on the PROFILE row as Handle and Discoverable, updated in the same transaction as the handle claim.

Notifications Table

Push devices, preferences, history and the outbox (issue #625, see notifications.md). Keys: PK, SK. Stream: NEW_IMAGE, filtered to new EVENT rows for the notification worker. TTL on ExpiresAt (unix seconds); reads check it too.

PK SK Attributes
USER#<u> DEVICE#<installationId> Token, Platform, AppVersion?, UpdatedAt, ExpiresAt (90-day lease, renewed on every registration)
TOKEN#<token> CLAIM UserId, InstallationId — the one install that owns an Expo push token
USER#<u> SETTINGS Push, Expenses, Settlements, JoinRequests, People, Comments, Reminders (default true), MutedGroupIds? (String Set), LastReadAt
USER#<u> NOTIF#<createdAt>#<eventId> NotificationId, Type, Title, Body, GroupId?, ExpenseId?, SettlementId?, JoinRequestId?, RecurringExpenseId?, ExpiresAt (90 days)
EVENT#<eventId> EVENT Type, GroupId?, ActorParticipantId?, ActorUserId?, ExpenseId?, SettlementId?, JoinRequestId?, SubjectUserId?, CommentId?, RecurringExpenseId?, Revision?, RecipientParticipantIds?, CreatedAt, PendingKey (until dispatched), ExpiresAt (7 days)
RECEIPTS#<yyyy-mm-ddThh:mm> TICKET#<ticketId> TicketId, Token, ExpiresAt (1 day): accepted Expo tickets awaiting a receipt check, by 15-minute bucket

GSI PendingEventsIndex (sparse): PendingKey / CreatedAt, ALL. Outbox events not yet dispatched, for the maintenance job's recovery sweep.

EVENT rows are written in the same transaction as the expense, settlement, join-request, mention or People change they describe. Event ids are <type>#<groupId-or-actorUserId>#<resourceId>[#<revision>]. Revisions distinguish expense versions, comment edits, friendship/invitation timestamps and recurring slots. The hourly job conditionally creates each recurring reminder once, using a due-index range query for upcoming slots. Per-group mute updates atomically ADD/DELETE a single group id from MutedGroupIds; no table/index migration is required.

Identities Table

Maps each external identity (provider, subject) to an internal UserId. A single TailSpend account accumulates several rows: the durable upstream anchor (google#… / apple#…, parsed from Cognito's federated identities metadata on first sign-in) plus the request-time cognito#<pool-sub> mapping. Anchoring on the upstream provider identity — never on email — means a Cognito pool replacement (which issues fresh subs) reconnects the same human to the same account via their google#…/apple#… row.

This table sits on the JWT-verification hot path: every authenticated request resolves cognito#<sub> from the verified token to a UserId via a single GetItem. Caching that mapping in-Lambda is reasonable but not required.

Keys

Attribute Type Key Description
IdentityKey String PK <provider>#<subject>. Globally unique per identity.

Attributes

Attribute Type Description
UserId String Internal user this identity is linked to. Indexed by UserIdentitiesIndex.
Provider String Lowercased identity provider id: google / apple for the durable upstream anchor, cognito for the request-time pool mapping. New direct-IdP integrations would record their own id.
Subject String Provider-issued subject for the user at that provider. For google/apple rows this is the upstream provider's user id (from the federated identities metadata); for cognito rows it is the user-pool sub.
CreatedAt String ISO 8601 timestamp, UTC (Z). When the link was created.

Key Format

Key Pattern Example
IdentityKey <provider>#<subject> cognito#7f4d2c9a-1b3e-4f5a-b8c9-d4e6f7a8b9c0

GSI: UserIdentitiesIndex

Lists every identity linked to a given UserId (e.g. for an account-settings screen showing "you can sign in with Google, Apple").

Attribute Type Key
UserId String GSI PK

Projection: ALL. The list-identities view wants Provider, Subject, and CreatedAt per row, so projecting everything keeps it a single read.

Access Patterns

Pattern Operation Key Condition
Resolve (provider, sub) → UserId on every request GetItem IdentityKey = "<provider>#<sub>"
List all identities linked to a user Query on UserIdentitiesIndex UserId = <uid>
Link a new identity to an existing user PutItem with attribute_not_exists(IdentityKey) New row keyed by <provider>#<sub>
Unlink an identity DeleteItem IdentityKey = "<provider>#<sub>"

The attribute_not_exists condition on link enforces the global uniqueness invariant: an external identity can be linked to at most one TailSpend user. If a user attempts to link an identity already attached to another account, the request fails with 409 Conflict.

The provisioning Lambda writes the new profile row and its identity rows (the upstream google#…/apple#… anchor and the cognito#<sub> mapping) in a single TransactWriteItems so a partial signup never leaves a user with no identities or an identity pointing at no user. A returning user with a new pool sub but a known upstream identity adds only the new cognito#<sub> row, linked to the existing UserId.

GroupData Table

Single-table layout: every per-group entity shares the GroupId partition, distinguished by sort-key prefix and EntityType.

Row kind SK prefix Purpose
Group head META One per group; label, currency, timestamps.
Invite INVITE Exactly one per group; public-invite-link enabled flag.
Join request JOINREQ#<requestId> One per pending or decided join request.
Join-request lock JOINREQ-PENDING-LOCK#<applicantUserId> Per-(applicant, group) pending lock or persistent denial guard; only a valid invite can replace a denial guard.
Participant PARTICIPANT#<participantId> Group member or guest. See the Participants section.
Expense head EXPENSE#<expenseId>#0#META Expense record. See the Expenses section.
Comment EXPENSE#<expenseId>#1#COMMENT#… Comment under an expense. See the Expenses section.
Recurring expense RECURRING#<recurringExpenseId> Template that materialises into expenses on its schedule.
Settlement SETTLEMENT#<createdAt>#<settlementId> A direct payment from one participant to another. See Settlements.

Keys

Attribute Type Key Notes
GroupId String PK Group the row belongs to
SK String SK Hierarchical sort key, see below

Sort key encoding

Row kind SK pattern Example
Group head META META
Invite INVITE INVITE
Join request JOINREQ#<requestId> JOINREQ#44444444-...
Join-request lock JOINREQ-PENDING-LOCK#<applicantUserId> JOINREQ-PENDING-LOCK#33333333-...
Participant PARTICIPANT#<participantId> PARTICIPANT#22222222-...
Expense head EXPENSE#<expenseId>#0#META EXPENSE#55555555-...#0#META
Comment EXPENSE#<expenseId>#1#COMMENT#<createdAt>#<commentId> EXPENSE#55555555-...#1#COMMENT#2025-…Z#66…
Recurring expense RECURRING#<recurringExpenseId> RECURRING#77777777-...
Settlement SETTLEMENT#<createdAt>#<settlementId> SETTLEMENT#2025-…Z#88888888-...

A single Query against GroupId = <gid> returns every row in the group. Most reads only need a subset; partition-bounded begins_with(SK, "PARTICIPANT#") etc. narrows to a single entity kind. Heavy access patterns use the GSIs below.

Attributes

Common to every row kind:

Attribute Type Description
GroupId String Partition key.
SK String Sort key, encoded as above.
EntityType String One of GROUP, INVITE, JOINREQ, JOINREQ_LOCK, JOINREQ_DENIED_LOCK, PARTICIPANT, EXPENSE, COMMENT, RECURRING_EXPENSE, or SETTLEMENT.
CreatedAt String ISO 8601 timestamp, UTC (Z).
UpdatedAt String ISO 8601 timestamp, UTC (Z).

Group-head-only attributes:

Attribute Type Description
Label String Human-readable group name.
Currency String ISO 4217 currency code for the group.

Invite-only attributes:

Attribute Type Description
Enabled Boolean Whether the public /invite reads return data or 404.
EnabledBy String ParticipantId of the OWNER who last toggled the flag.

Join-request-only attributes:

Attribute Type Description
RequestId String UUID v4 for the request. Set only on JOINREQ rows so PendingJoinRequestsIndex is sparse.
ApplicantUserId String UserId of the caller who submitted the request.
ApplicantDisplayName String Display name snapshot at submit time, used in the OWNER review UI even if the user later renames themselves.
Mode String TAKEOVER (bind to existing GUEST) or NEW (create fresh USER participant).
TargetParticipantId String Set only on Mode = TAKEOVER: the GUEST ParticipantId the request will claim.
RequestedDisplayName String Set only on Mode = NEW: the display name the new participant will hold.
ProposedRole String One of MEMBER or VIEWER.
Status String PENDING, APPROVED, DENIED, or CANCELLED.
DecidedAt String ISO 8601 timestamp. Set when an OWNER approves/denies or the applicant cancels.
DecidedBy String ParticipantId of the OWNER on approve/deny; the applicant's UserId on cancel.
FinalRole String Role chosen by the OWNER on approve. Absent until then.
FinalParticipantId String Resolved ParticipantId written into Participants on approve. Equals TargetParticipantId for TAKEOVER.
PendingKey String Sparse — set only when Status = PENDING. Equals <CreatedAt>#<RequestId>. Powers the GSI below.
UserPendingKey String Sparse — set only when Status = PENDING. Equals the applicant's UserId (a copy of ApplicantUserId). Serves as the partition key of the GSI below.

GSI: PendingJoinRequestsIndex (sparse)

Lists pending join requests for a group, in chronological order, without scanning decided rows.

Attribute Type Key
GroupId String GSI PK
PendingKey String GSI SK

Sparse: only items with PendingKey set are indexed; setting Status to anything other than PENDING removes the attribute and drops the row from the index.

Projection: ALL. The OWNER review UI shows everything on the row.

GSI: UserPendingJoinRequestsIndex (sparse)

Inverts the table for the applicant: "do I already have a pending request to group X?", and "list all my pending requests across groups".

Attribute Type Key
UserPendingKey String GSI PK
GroupId String GSI SK

UserPendingKey is both the sparseness signal and the partition key — present only on Status = PENDING rows, where it holds the applicant's UserId. The PK/SK pair is then (UserPendingKey, GroupId), which keeps "do I have a pending request to this group?" a one-row Query.

Projection: ALL. The applicant view renders the request body without a follow-up base-table read.

Access Patterns

Pattern Operation Key Condition
Read group head GetItem GroupId = <gid> AND SK = "META"
Read invite GetItem GroupId = <gid> AND SK = "INVITE"
Toggle invite enabled flag UpdateItem GroupId = <gid> AND SK = "INVITE"
Read a join request by id GetItem GroupId = <gid> AND SK = "JOINREQ#<rid>"
List pending join requests in a group (paged) Query on PendingJoinRequestsIndex GroupId = <gid>, scan index forward = false
List caller's pending requests across groups Query on UserPendingJoinRequestsIndex UserPendingKey = <uid>
Pre-check "is there already a pending request?" Query on UserPendingJoinRequestsIndex UserPendingKey = <uid> AND GroupId = <gid>
Submit a join request TransactWriteItems: pending lock + request + outbox Lock requires attribute_not_exists(SK); a valid invite may instead replace EntityType = JOINREQ_DENIED_LOCK. Request is new, Status = PENDING, sparse keys set.
Cancel own pending request UpdateItem with Status = "PENDING" condition GroupId = <gid> AND SK = "JOINREQ#<rid>", REMOVE PendingKey, UserPendingKey, SET Status = "CANCELLED"
Deny a pending request TransactWriteItems: conditional request update + denial guard + outbox Request must be PENDING; set DENIED, remove sparse keys, replace pending lock with JOINREQ_DENIED_LOCK containing ApplicantUserId, RequestId, DeniedAt. No TTL or GSI keys. Earlier denials are not backfilled.
Approve a pending request (TAKEOVER or NEW) TransactWriteItems across Groups+Participants See Claim flow invariants
Create a group + OWNER + invite (atomic) TransactWriteItems across Groups (×2) + Participants Three Puts, each guarded by attribute_not_exists

Expenses

Expenses and their comments live in the GroupData table — same partition (GroupId) as the group head, invite, participants, and join requests. Expenses occupy two SK shapes:

Row kind Purpose
Expense One per expense, holds payments and items inline
Comment One per comment on an expense

A single Query against GroupId = <gid> AND begins_with(SK, "EXPENSE#<eid>#") returns the expense first and then all of its comments in chronological order, in one round-trip.

Keys

Attribute Type Key Notes
GroupId String PK Group the row belongs to
SK String SK Hierarchical sort key, see below

Sort key encoding

Row kind SK pattern Example
Expense EXPENSE#<expenseId>#0#META EXPENSE#e7e7e7e7-...#0#META
Comment EXPENSE#<expenseId>#1#COMMENT#<createdAt>#<commentId> EXPENSE#e7e7e7e7-...#1#COMMENT#2025-05-24T13:45:00Z#c0ffee00-...

The 0 / 1 segment between the expense id and the row kind is intentional. Lexicographic order on SK puts 0 before 1, so a Query with begins_with(SK, "EXPENSE#<eid>#") returns the expense head first, followed by all comments in chronological order, in a single round-trip.

Embedding createdAt in the comment sort key gives chronological ordering without a separate index, and the trailing commentId keeps it unique if two comments arrive in the same millisecond. All createdAt values in SK segments are required to be UTC with a Z suffix (see Invariants) so lexicographic order matches chronological order.

Attributes

Common to both row kinds:

Attribute Type Description
GroupId String Partition key.
SK String Sort key, encoded as above.
EntityType String EXPENSE or COMMENT. Convenient for filters and downstream consumers.
CreatedAt String ISO 8601 timestamp, UTC (Z).
UpdatedAt String ISO 8601 timestamp, UTC (Z).

Expense-only attributes:

Attribute Type Description
ExpenseId String UUID for the expense. Set only on expense rows so ExpenseIdIndex stays sparse.
ExpenseDate String User-selected calendar date (YYYY-MM-DD) on which the expense occurred. Legacy rows fall back to the date portion of CreatedAt.
Label String Human-readable description.
Currency String ISO currency code for the expense.
Payments List Payment splits, each { participantId, amount, charge }.
Items List Line items, each { id, label, cost, split }. Splits reference participants by participantId.
Version Number Monotonically increasing integer, incremented on every successful update. Used as a ConditionExpression guard for optimistic concurrency. See Invariants.
CreatedBy String ParticipantId of the caller who created the expense. The trusted basis for the MEMBER edit-own authorization check. Optional: rows written before this attribute existed have none, and readers fall back to the legacy Payments[0] heuristic.

Comment rows reference their parent expense purely through the sort key prefix (EXPENSE#<expenseId>#1#COMMENT#…); they do not duplicate ExpenseId as an attribute. This keeps ExpenseIdIndex sparse on expense rows.

Comment-only attributes:

Attribute Type Description
CommentId String UUID for the comment.
CreatedBy String ParticipantId of the author. For system-generated comments, the special value SYSTEM.
MentionedParticipantIds List Optional explicit registered group participant ids (at most 20), validated on writes and rechecked before dispatch.
Message String Comment body.

GSI: GroupExpensesIndex (sparse)

Lists only expense rows in a group, so paginating "all expenses in this group" doesn't read comment rows. Sparse: only items with ExpenseSortKey set are indexed, and only expense rows set it.

Attribute Type Key Notes
GroupId String GSI PK
ExpenseSortKey String GSI SK <expenseDate><createdAt time suffix>#<expenseId> on expense rows only

ExpenseSortKey lets clients page through expenses by the selected occurrence date, preserving creation-time order within a day. Its shape remains lexicographically compatible with legacy <createdAt>#<expenseId> values.

Projection: ALL. The list-expenses endpoint returns full expense documents, so projecting all attributes avoids a follow-up base-table read per item.

GSI: ExpenseIdIndex (sparse)

Looks up an expense by id alone (e.g. when the caller does not know the group). Sparse on ExpenseId, which is set only on expense rows, so comment rows are not indexed.

Attribute Type Key
ExpenseId String GSI PK
SK String GSI SK

Projection: KEYS_ONLY. Callers use this index to resolve ExpenseId → (GroupId, SK) and then GetItem on the base table for the full row. Keeping the projection minimal keeps storage cost and write amplification low.

Access Patterns

Pattern Operation Key Condition
Get an expense and its comments Query GroupId = <gid> AND begins_with(SK, "EXPENSE#<eid>#")
Get only the expense head GetItem GroupId = <gid> AND SK = "EXPENSE#<eid>#0#META"
List expenses in a group (paged, recent first) Query on GroupExpensesIndex GroupId = <gid>, scan index forward = false
Get expense by id (cross-group) Query on ExpenseIdIndex ExpenseId = <eid>
List comments on an expense Query GroupId = <gid> AND begins_with(SK, "EXPENSE#<eid>#1#COMMENT#")
Add a comment PutItem New comment row in the expense partition
Edit a comment UpdateItem GroupId = <gid> AND SK = <comment SK>
Delete a comment DeleteItem GroupId = <gid> AND SK = <comment SK>
Update an expense (with concurrency check) UpdateItem GroupId = <gid> AND SK = "EXPENSE#<eid>#0#META", ConditionExpression: Version = :expected
Delete an expense and all its comments Paginated Query then BatchWriteItem (TransactWriteItems for ≤ 100) All rows where begins_with(SK, "EXPENSE#<eid>#")

Deleting an expense fans out across its comment rows. The delete re-queries the partition (paging past the 1 MB Query limit) and deletes every page until the partition is empty, so a thread larger than one page can't leave orphaned comments behind (see #241). Pages of ≤ 100 keys go through an atomic TransactWriteItems; larger pages fall back to BatchWriteItem.

Participants

Tracks every entity that can appear in a group's expenses — authenticated users and unauthenticated guests alike. Participant rows live in the GroupData table at SK = "PARTICIPANT#<participantId>". Authorization is role-based at the group level: every expense and comment in a group inherits the actor's group role. There are no per-expense or per-comment ACLs.

A guest is a row with Type = GUEST and no UserId; on claim (see Claim flow invariants), the same row flips to Type = USER with a UserId attached. Because expense Payments and item splits reference ParticipantId, claiming a guest never rewrites historical expenses — it's a single-row mutation.

Keys

Attribute Type Key Description
GroupId String PK Group the participant belongs to.
SK String SK PARTICIPANT#<participantId>.
ParticipantId String attr UUID v4 (also encoded into SK). Stable for the participant's lifetime; survives guest → user claim.

A begins_with(SK, "PARTICIPANT#") Query lists participants of a group; a point GetItem on (GroupId, "PARTICIPANT#<pid>") resolves "is this participant in this group, and what's their role?" in one read.

Attributes

Attribute Type Description
Type String One of USER or GUEST. Set to GUEST on guest creation; flipped to USER on claim.
Role String One of OWNER, MEMBER, or VIEWER. See Roles.
DisplayName String Human-readable name shown in clients. For GUEST, set by the OWNER. For USER, it mirrors the account's profile DisplayName: seeded from it on create/direct add, adopted from it on a TAKEOVER claim, and rewritten across every one of the user's groups when they rename themselves via PATCH /users/me (see USER display-name sync). A NEW-mode join-request approval seeds the row from the request body, and the next profile rename brings it back in line.
UserId String Internal user id. Set only when Type = USER, so the UserParticipantsIndex GSI is sparse.
CreatedAt String ISO 8601 timestamp, UTC (Z). When the participant was created.
UpdatedAt String ISO 8601 timestamp, UTC (Z). Bumped on Role change, claim, or rename.
ClaimedAt String ISO 8601 timestamp, UTC (Z). Set when a guest is claimed; absent on guests and on participants who joined as users from the start.

Roles

The role set is closed and small. Adding a role is a deliberate, schema-level change.

Role Capabilities
OWNER Everything MEMBER can do, plus: edit group metadata, add/remove participants, change roles, enable/disable the invite link, approve/deny join requests, delete the group.
MEMBER Read the group, read all expenses and their comments, create/edit/delete their own expenses and comments.
VIEWER Read-only: read the group, expenses, and comments. Cannot write anything.

GUEST participants have no Role capabilities until they're claimed — they can be referenced in expenses but cannot themselves authenticate. Their Role attribute records the role they will hold post-claim, so the OWNER's intent ("I'm inviting them as a VIEWER") is durable from the moment the guest is created.

A group always has at least one OWNER. Removing the last OWNER, demoting the sole OWNER, or deleting the participant row of the sole OWNER is rejected by the API tier. This is enforced atomically via an OwnerCount number on the group head row (SK = "META"): every owner add / remove / role-flip mutates OwnerCount in the same TransactWriteItems as the participant write, and demotions/removals condition the head write on OwnerCount > 1 so the guard can't be defeated by a read-then-write race (two concurrent demotions of different OWNERs when exactly two exist).

USER uniqueness row (USERMEMBER#<userId>)

To enforce one USER participant per group, every Type = USER participant write also writes a sibling uniqueness row at SK = "USERMEMBER#<userId>" under attribute_not_exists(SK), in the same TransactWriteItems as the participant PUT. The participant row's own SK is a fresh PARTICIPANT#<uuid>, so its attribute_not_exists(SK) guard can never collide on a repeat join — the uniqueness row, keyed on the stable userId, is the actual guard. A repeat join cancels the transaction (TransactionCanceledException), which the API tier maps to 409 Conflict. Removing a USER participant deletes its uniqueness row in the same transaction so the user can re-join. The USERMEMBER# prefix is distinct from PARTICIPANT#, so the roster begins_with(SK, "PARTICIPANT#") Query never surfaces these rows; the group cascade-delete sweeps them with the rest of the partition.

USER display-name sync

A USER participant row's DisplayName is a denormalized copy of the account's profile name — it is read on the group hot path (roster, expense rows, balances), so storing it on the row avoids a per-participant join against the UserData table on every read.

The copy is kept in step at the write that can invalidate it: PATCH /users/me walks UserParticipantsIndex for the caller and rewrites DisplayName on every participant row it finds (ParticipantsRepository.renameUserParticipants). Each row's update is conditioned on attribute_exists(SK), so a participant removed between the eventually-consistent GSI read and the write is skipped rather than resurrected.

The fan-out is best-effort: the profile row is the source of truth and is written first, so a throttled or partially-applied fan-out never fails the user's rename. It is logged (users.participant_name_sync_failed) and every write is idempotent, so the next rename re-converges. A guest's group-local name is untouched — only USER rows mirror an account.

GSI: UserParticipantsIndex (sparse)

Inverts the table for actor-centric queries: "what groups is this user a participant in?" — the home-screen list. Sparse on UserId, which is set only on USER participants, so guest rows are not indexed.

Attribute Type Key
UserId String GSI PK
GroupId String GSI SK

Projection: INCLUDE (Role, ParticipantId). The home-screen list needs the role to render group cards (e.g. show an "owner" badge or a settings button) and the ParticipantId so subsequent same-screen actions don't need to re-resolve it. CreatedAt / UpdatedAt aren't needed for this view, so we skip the cost of ALL.

Access Patterns

Pattern Operation Key Condition
Resolve a participant and their role GetItem GroupId = <gid> AND SK = "PARTICIPANT#<pid>"
List participants of a group Query GroupId = <gid> AND begins_with(SK, "PARTICIPANT#")
List groups for a user (home screen) Query on UserParticipantsIndex UserId = <uid>
Resolve a user's participant in a specific group Query on UserParticipantsIndex UserId = <uid> AND GroupId = <gid> (or GetItem if the caller already has ParticipantId from the JWT-side cache)
Add a guest PutItem with attribute_not_exists(SK) New row, Type = GUEST, no UserId
Add a user directly (e.g. group create) PutItem with attribute_not_exists(SK) New row, Type = USER, UserId set
Change a participant's role UpdateItem GroupId = <gid> AND SK = "PARTICIPANT#<pid>"
Rename a participant UpdateItem GroupId = <gid> AND SK = "PARTICIPANT#<pid>"
Claim a guest (flip GUEST → USER) UpdateItem with ConditionExpression: Type = "GUEST" GroupId = <gid> AND SK = "PARTICIPANT#<pid>"
Remove a participant DeleteItem GroupId = <gid> AND SK = "PARTICIPANT#<pid>"

The claim is a single UpdateItem whose ConditionExpression ensures the participant is still a guest (so a double-claim races safely and the loser gets ConditionalCheckFailedException → 409 Conflict).

The authorization hot path resolves (authenticated UserId, requested GroupId) → ParticipantId, Role via Query on UserParticipantsIndex. The query is on a single-item collection (UserId partition + GroupId sort key), so it's a point-shaped read; the indirection through ParticipantId is what makes guest claim a single-row mutation instead of a fan-out across every expense in the group.

Claim flow invariants

These constraints govern the join-request approval flow end-to-end. They live here rather than in Invariants because they span join-request and participant rows in the GroupData table.

Invariants

These rules apply across the schema and must be enforced on the write path. Read paths can rely on them.

Recurring expense rows

Recurring-expense templates are another row kind in the GroupData table — keyed (GroupId, "RECURRING#<id>") — alongside the group head, invite, join-request, participant, expense, and comment rows. Single-table design keeps a template colocated with the materialised expenses it produces, in one partition.

Sort key

Row kind SK pattern Example
Recurring expense RECURRING#<recurringExpenseId> RECURRING#7f4d2c9a-…

Attributes (recurring rows)

Attribute Type Description
RecurringExpenseId String UUID v4 for the template.
Label String Human-readable name (e.g. Netflix).
Currency String ISO currency code; immutable after create.
Payments List Same { participantId, amount, charge } shape as expenses; cloned onto each materialised expense.
Items List Same { label, cost, split } shape as expenses (item ids are minted at materialisation time).
Unit String One of WEEK, MONTH, YEAR. Sub-weekly cadence is not encodable.
IntervalCount Number Integer in [1, 52].
NextRunAt String UTC top-of-hour ISO 8601 (Z suffix). Doubles as the slot-claim concurrency token: the tick's UpdateItem is conditioned on NextRunAt = :prev.
DueBucket String Sparse — set to the literal "DUE" only when Status = ACTIVE. PK of DueRecurringExpensesIndex.
Status String ACTIVE or PAUSED. Toggling to PAUSED REMOVEs DueBucket so the row drops out of the due GSI; toggling back to ACTIVE SETs it.
CreatedAt/UpdatedAt String ISO 8601 UTC timestamps.

GSI: DueRecurringExpensesIndex (sparse)

A sparse GSI on the GroupData table, alongside the participant, expense, and join-request indexes.

Attribute Type Key
DueBucket String GSI PK
NextRunAt String GSI SK

Projection: ALL. The hourly tick reads due rows and materialises expenses without a follow-up base-table read. Sparse on DueBucket so paused rows are skipped automatically.

Access Patterns (recurring rows)

Pattern Operation Key Condition
Get a recurring expense by id GetItem GroupId = <gid> AND SK = "RECURRING#<rid>"
List recurring expenses in a group Query on base table GroupId = <gid> AND begins_with(SK, "RECURRING#")
Find every ACTIVE row due now Query on DueRecurringExpensesIndex DueBucket = "DUE" AND NextRunAt <= <nowIso>
Claim a slot (advance NextRunAt) UpdateItem GroupId = <gid> AND SK = "RECURRING#<rid>", ConditionExpression: NextRunAt = :prev
Pause / resume UpdateItem Sets Status and SET/REMOVE DueBucket to add/remove from the due GSI
Delete a template DeleteItem GroupId = <gid> AND SK = "RECURRING#<rid>" (existing materialised expenses retain)

The hourly tick reads the entire due set across all GSI pages before mutating any row, then processes the snapshot serially. Because the slot-claim advances NextRunAt — the due GSI's sort key — paging the index while mutating it could otherwise skip rows (moved past the cursor) or revisit them (moved into a later page). Slot-claim conditions on NextRunAt = :prev so a duplicate tick (or parallel reader of the same row) finds the slot already advanced and receives ConditionalCheckFailedException — the slot itself is the concurrency token; no separate Version attribute is needed. Domain failures during materialisation (group deleted, participant removed) leave the slot claimed and surface as a failed count so the template doesn't re-attempt every hour; transient failures (throttling, Lambda kill) roll NextRunAt back to the claimed slot (conditioned on the advanced value still holding) and rethrow, so the occurrence is retried next tick instead of being silently dropped. Single-bucket layout is fine at the expected scale; if a future workload outgrows one partition, the GSI key can be sharded by hashing the id without changing the public surface.

Settlements

Settlements are a row kind in the GroupData table — keyed (GroupId, "SETTLEMENT#<createdAt>#<settlementId>") — recording a direct payment from one participant to another ("A paid B 20.00"). Unlike an expense a settlement has no items or splits: it is a single transfer of Amount (group-currency minor units) from PayerId to PayeeId.

Sort key

Prefixing the SK with createdAt (ahead of the id) makes a base-table begins_with(SK, "SETTLEMENT#") query with ScanIndexForward: false return a group's settlements newest-first, so the transaction-history list needs no dedicated GSI. A settlement has no child rows, so the id can trail the timestamp without breaking any head-before-children ordering (contrast the expense head's #0#META / #1#COMMENT scheme).

Row kind SK pattern Example
Settlement SETTLEMENT#<createdAt>#<settlementId> SETTLEMENT#2025-…Z#88888888-…

Attributes (settlement rows)

Attribute Type Description
SettlementId String UUID v4 for the settlement.
PayerId String ParticipantId who made the payment (their debt goes down).
PayeeId String ParticipantId who received it (what they were owed goes down). Must differ from PayerId.
Amount Number Positive integer, group-currency minor units. Always denominated in the group currency.
Currency String ISO currency code; always equals the group's currency (copied from the head at create time).
Note String Optional free-text note (e.g. Venmo, cash).
Version Number Optimistic-concurrency token; UpdateItem conditions on it and increments it.
CreatedBy String ParticipantId of the caller who recorded it; the trusted basis for the MEMBER edit-own check.
CreatedAt/UpdatedAt String ISO 8601 UTC timestamps. CreatedAt is embedded in the SK and therefore immutable.

Effect on balances

A settlement shifts the running net (net = paid − owed, positive when owed): the payer's net moves up by Amount and the payee's net moves down by the same amount, so the group's balances still sum to zero. The balance projection (GET /groups/{groupId}/balances) reads all expenses + all settlements + the participant roster, computes each participant's net, and runs a greedy debt-simplification to produce the minimal "who owes who" transfer plan. The per-caller dashboard balance (GET /users/me/groups) folds settlements into the same net so the two never diverge.

Access Patterns (settlement rows)

Pattern Operation Key Condition
List settlements (newest first) Query on base table GroupId = <gid> AND begins_with(SK, "SETTLEMENT#"), ScanIndexForward: false
Aggregate all settlements (balances) Paginated Query GroupId = <gid> AND begins_with(SK, "SETTLEMENT#") (page-walk, bounded)
Find a settlement by id Query + filter begins_with(SK, "SETTLEMENT#") AND FilterExpression: SettlementId = :sid
Update a settlement UpdateItem GroupId = <gid> AND SK = "SETTLEMENT#<createdAt>#<sid>", ConditionExpression: Version = :expected
Delete a settlement DeleteItem GroupId = <gid> AND SK = "SETTLEMENT#<createdAt>#<sid>"

Locating a settlement by id requires a filtered query (the createdAt in the SK is unknown to the caller), mirroring how expense comments are resolved by CommentId. Settlement rows are swept by the group-delete cascade automatically — it deletes every row in the GroupId partition.

Operational notes

Materialized group net balances

(GroupId, SK = BALANCE#v1) stores BalanceRevision (number), Initialized (boolean), ExpenseCount, SettlementCount, and numeric Net_<participantId> attributes. Positive net means owed; negative means owing. Each source mutation adds old-to-new net deltas, activity counts and one revision atomically. Uninitialized legacy rows accumulate revisions until backfilled; their partial nets are never served. See migration and rollout.

Pending direct invitations may include ParticipantId pointing to a reserved GUEST participant with InvitedUserId and no UserId or USERMEMBER row. Reservation and invitation are atomic. Accept consumes the invitation, conditionally claims that guest and creates the uniqueness row in one transaction. Its stable ID preserves already assigned expenses. Missing/changed slots return a conflict; acceptance never silently creates a different financial identity.

InviteExpiresAt records the active reservation deadline on a guest; InvitedUserId is a durable expense-identity link, never a membership/GSI key. Decline/revoke atomically deletes the invitation and removes the matching InviteExpiresAt, retaining the guest, history and link for reinvite. Expiry leaves history intact. Owner invitation creation may link an existing unreserved guest through a conditional update. Legacy invitations missing ParticipantId are repaired by a conditional cross-table transaction that preserves the original approval/expiry. Acceptance conditions on the invitation's creation, deadline and participant ID, and on the guest's linked account and active deadline. Generic TAKEOVER approvals reject a live reservation and clear stale reservation attributes when claiming. Account deletion clears outstanding guest reservations before deleting invitations.

TransferTarget is present only while a participant transfer is in progress and stores its fixed destination participant ID. A conditional transaction locks the source and checks that the target exists and is not transferring. Expense, settlement and recurring-template writes condition-check every newly supplied participant reference in the same transaction; a locked/missing participant returns 409. Transfers use optimistic expense/settlement versions and recurring UpdatedAt checks, and retain the source lock after partial failure so the same operation can resume safely. A successful transfer unlocks a retained source or removes it after strongly consistent financial-reference checks, including recurring templates. These checks query the base table with an EntityType filter for EXPENSE and RECURRING_EXPENSE, paging past empty filtered pages; sort-key predicates belong in key conditions, never in query filters. No new table or index is introduced.

Expense chat records (UserData)

Expense chat reuses the user table and existing balance/ledger:

UserId SK Purpose
Caller ID CHAT#<chatId> Private transcript, draft, version, frozen date/timezone, active attempt deadline/payload, confirmation hash/marker; 24-hour ExpiresAt TTL
Caller ID CHATCOUNT#YYYY-MM-DD Atomic paid message count, UTC day, three-day TTL
Caller ID TXN#<ISO timestamp>#<uuid> Existing ledger format, new SPEND_EXPENSE_CHAT type, exact balance after debit
__EXPENSE_CHAT__ CHATBUDGET#YYYY-MM-DD Reserved USD millionths for all attempts in this stage/day; three-day TTL
__EXPENSE_CHAT__ CHATWORK#<userId>#<chatId> Durable work pointer with IDs only; same expiry as session

Admission writes session, balance, ledger, counters and work pointer in one conditional transaction, also checking PROFILE existence. Worker claims/results use optimistic version guards; finish removes the work pointer atomically. Outbox reconciliation queries the service partition (no Scan). Chat-only DynamoDB clients request consumed capacity and log aggregate read/write units for serving-cost validation, never keys or content. A saved marker commits in the same transaction as the expense/balances/notification, so even deletion of the created expense cannot allow confirmation replay to recreate it. Account deletion sweeps the complete user partition; orphaned global work pointers are removed when the backstop sees a missing session.

OpenAI MCP connections (#762)

MCP reuses the Users table and its existing ExpiresAt TTL configuration. No new table, index or database engine is required. Grants/session bindings do not set scan-recovery index attributes and do not match PROFILE/BALANCE/ledger keys. All authorization/grant reads are strongly consistent; TTL is not authorization.

UserId SK Data / retention
MCP#AUTH#<request UUID> REQUEST Validated client/callback, original state, PKCE challenge, scopes, approval state and approved user/grant IDs; ten minutes
MCP#CODE#<SHA-256 code> CODE Request ID, single-use claim, at most five minutes and no longer than the request
<owner UserId> MCP#GRANT#<grant UUID> Client, groups, scopes, created time; PENDING → ACTIVE → REVOKED
<owner UserId> MCP#SESSION#<SHA-256 origin_jti> Stable grant ID for an authenticated Cognito token family
MCP#REFRESH#<SHA-256 refresh token> REFRESH User/grant locator and single-use claim; TTL 31 days; Cognito independently enforces actual token expiry
<owner UserId> MCP#POINTER#<locator UserId> Cleanup pointer for approved requests, code and refresh locators; TTL matches the target

Approval conditionally consumes one unexpired request and creates a pending grant. Callback consumes the approval and creates a code-hash claim. Token exchange/ refresh atomically updates the still-approved grant, binds the token family and records a new refresh hash and cleanup pointer. The prior refresh hash is claimed once before the upstream exchange. No code, access token, ID token, plaintext refresh token or token-response cache is persisted.

Disconnect changes the grant to REVOKED, checked on every request and refresh. A new connection has a different grant ID; old token families cannot be rebound to it. Session bindings are removed by account deletion. Before the normal user partition sweep, account deletion follows every MCP cleanup pointer and removes its off-partition locators; each batch deletes its targets before its pointers, preserving progress when account deletion is retried. Unapproved anonymous authorization requests expire after ten minutes.

MCP usage is recorded in the existing developer daily aggregate namespace, with tokenId equal to the stable grant UUID. Its name is displayed as OpenAI. PAT and MCP activity share DEV#USER#<owner> budgets and developer create allowances; refresh and additional grants do not reset the per-user counters.

View Markdown source