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
- Per-route Lambda + DynamoDB scales to zero and absorbs spikes without capacity planning.
- All hot paths are point lookups or partition-bounded queries, which DynamoDB serves at single-digit millisecond latency.
- Item splits stay nested on the expense row (small, always read with the expense, never queried independently), but comments are first-class rows so concurrent writes, long threads, and comment-by-id access all behave well.
- Group participation lives as
PARTICIPANT#<participantId>rows in the sameGroupDatapartition. A guest is a row withType = GUESTand noUserId; on claim, the same row flips toType = USERwith aUserIdattached. Because expenses referenceParticipantId, claiming a guest never rewrites historical expenses — it's a single-row mutation. - Everything per-group sharing the
GroupIdpartition means "approve a join request" is a singleTransactWriteItemstouching the JOINREQ row, the participant row, and the lock row in one atomic operation; "delete a group" is a single bounded sweep over the partition.
Tables at a glance
The diagram is the logical model. Physically, four DynamoDB tables back the domain (plus the rate-limit counter table):
UsersandIdentitiesare keyed byUserId/IdentityKeyand stay separate. TheUserspartition also holds daily API usage aggregates (see API usage rows).DeveloperTokensholds personal access tokens and developer idempotency records (see DeveloperTokens Table).Peopleholds discovery and social state, andNotificationsholds push devices, preferences, history and the outbox (see Notifications Table).- The
GroupDatatable holds every per-group entity (group head, invite, join requests, participants, expenses, comments, recurring templates) in a singleGroupIdpartition, distinguished by sort-key prefix andEntityType.
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.
- Entity-type prefixes in keys are UPPERCASE.
EXPENSE#…,COMMENT#…,JOINREQ#…,META,INVITE. This matches the existingExpensesSK encoding and the community single-table-design convention. AWS examples sometimes use lowercase short prefixes (p#,c#); both are valid, and we pick UPPERCASE for readability and grep-ability. - Roles and stored enum values are UPPERCASE.
OWNER,MEMBER,VIEWER,GROUP,INVITE,JOINREQ,EXPENSE,COMMENT,USER,GUEST,SYSTEM,PENDING,APPROVED,DENIED,CANCELLED,TAKEOVER,NEW. These are stored tokens, not display strings. - Identity-provider names are lowercase. Embedded in
IdentityKeysegments only (e.g.google#…,apple#…). Provider ids come from the IdP and are normalized to lowercase on write. - UUIDs are lowercase v4 with hyphens, as emitted by standard libraries.
- Timestamps are ISO 8601 UTC with a literal
Zsuffix. Required wherever a timestamp is embedded in a sort key or sort-key segment so lex order matches chronological order. - Reserved words.
Items,Version,Comments,Currency,Nameare DynamoDB reserved words. All access must useExpressionAttributeNames(#items,#version, etc.).
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:
- the Cognito provisioning trigger stamps
IsTestAccount: trueon the profile row, from the pool's verified email, so the flag is server-owned and no client can claim it; - the services layer re-derives the mark from
Emailwhen the flag is absent, so rows written before the flag existed are recognised with no backfill; - the integration-test harness builds its usernames from the same constant, so the marking rule and the harness can't drift apart.
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.
- Stable
ParticipantId. A guest'sParticipantIdis created on guest creation and never changes — including acrossTAKEOVERclaim. All historical expensePaymentsand item splits remain valid. - Atomic approve, transactionally. Approve is a single
TransactWriteItemsagainst theGroupDatatable whose shape depends onMode:TAKEOVER:UpdateItemflipping the join-request row fromStatus = "PENDING"toAPPROVED(and removing the sparse keys), andUpdateItemon the participant row flippingTypefromGUESTtoUSER, settingUserId, the OWNER's chosenRole, andClaimedAt. The first item is conditioned onStatus = "PENDING"; the second onType = "GUEST". If either condition fails, the whole transaction fails.NEW:UpdateItemon the join-request row (same shape as above) plusPutItemon a fresh participant row withattribute_not_exists(SK)for a newly minted UUID.- In both modes a partial success is impossible. Cancellation reasons map: index
[0]failed →409 Conflict"Request is no longer pending"; index[1]failed →409 Conflict(target no longer a guest, or participant id collision).
- Already-a-member rejection. Submitting a join request pre-checks
UserParticipantsIndexfor an existing(UserId, GroupId)participant; if found, the submit returns409 Conflict. The same check is repeated on approve to defend against an OWNER racing with a parallel approve in another tab. - At most one pending request per
(UserId, GroupId). Submitting pre-checksUserPendingJoinRequestsIndexfor an existing row at(UserPendingKey = applicant UserId, GroupId); if found, the submit returns409 Conflict. The applicant must cancel their pending request before submitting a fresh one. - Cascade on guest deletion. Deleting a guest participant (e.g. the OWNER removes the guest before anyone has joined) converts every PENDING
JOINREQrow whoseTargetParticipantIdmatches intoCANCELLEDso the OWNER list does not show stale "claim a guest who no longer exists" rows. Implemented inParticipantsService.removeParticipant: aQueryonPendingJoinRequestsIndex(sparse onPendingKey) with aFilterExpressiononTargetParticipantId, followed by a per-rowTransactWriteItemsthat flips the row toCANCELLED, REMOVEs the sparse keys, and deletes the per-applicant pending lock. Per-row cancellations swallowConditionalCheckFailedExceptionso a concurrent OWNER decision on one of the rows doesn't fail the whole pass. - OWNER promotion is forbidden via join requests. The proposed and final roles are limited to
MEMBERorVIEWER. Promoting a participant toOWNERrequires an existing OWNER's explicit role-change action viaPATCH /groups/{groupId}/participants/{participantId}. - Disabled invite reads as missing. A
GET /groups/{groupId}/invite*against a disabled or absent invite returns404 Not Found, never200with stale data. The handler reads(<groupId>, "INVITE")and rejects whenEnabled === falsebefore reading anything else.
Invariants
These rules apply across the schema and must be enforced on the write path. Read paths can rely on them.
- UTC timestamps only. Every
createdAt/updatedAtvalue, including the segments embedded inSKandExpenseSortKey, must be ISO 8601 with a literalZsuffix. Mixing offsets breaks the lex-sort guarantees the schema relies on for chronological reads. - Optimistic concurrency on expenses. Expense rows carry a
Version(number, starts at1). EveryUpdateItemincrements it and conditions on the previously-read value viaConditionExpression: Version = :expected. Concurrent edits surface asConditionalCheckFailedException, which the API tier maps to409 Conflict. ExpenseIdis set only on expense rows. This keepsExpenseIdIndexsparse to expense heads. Comment rows reference their parent through theSKprefix instead.- Row-kind segments in
SKuse0/1.0#METAfor the expense head and1#COMMENT#…for comments, so a singleQueryreturns the head before any comments without server-side filtering. New row kinds added later must pick2,3, … and never reuse0or1. - Key-encoded attributes are immutable after create.
SK,ExpenseSortKey,IdentityKey, and the timestamp segments inside them must never be mutated by anUpdateItem. Renaming or re-timestamping requires a delete-and-recreate. The write path enforces this by never including these attributes inUpdateExpression. - Conditional creates. All entity creates use a
ConditionExpressionto make retries safe and to enforce uniqueness:Users.PutItemusesattribute_not_exists(UserId).Identities.PutItemusesattribute_not_exists(IdentityKey)(also enforces global identity uniqueness).- New
GroupDatarows (group head, invite, JOINREQ, participant, expense head, comment, recurring) useattribute_not_exists(SK). The per-applicant join-request lock also requires absence, except a valid invite may atomically replace aJOINREQ_DENIED_LOCK. A denial replaces its pending lock in the same transaction as the conditional request update, preventing tokenless resubmission races. A retry from a Lambda timeout therefore either succeeds idempotently or surfaces aConditionalCheckFailedExceptionthat the API tier maps to409 Conflict.
- Group + owner + invite are written transactionally. Creating a group writes four rows into the
GroupDatapartition in a singleTransactWriteItemscall: theMETAgroup head, theINVITErow, the creator'sOWNERparticipant row, and an initializedBALANCE#v1row. If any one fails, the whole operation rolls back, so a group never exists without an owner or without its invite settings. - Group always has at least one OWNER. Removing the last
OWNERfrom a group is rejected by the API tier (returns409 Conflict). Demoting the sole owner is also rejected. Enforced atomically via the head row'sOwnerCount(see Participants → Roles): the participant write and the guardedOwnerCountmutation (OwnerCount > 1on a decrement) ride in oneTransactWriteItems, so two concurrent demotions/removals can't both succeed and zero out the owners.OwnerCountis seeded to1increateGroupTransact; groups predating the attribute are self-healed from acountOwnersread seeded viaif_not_existson the first owner mutation. - One USER participant per group. A
Type = USERparticipant write also writes aUSERMEMBER#<userId>uniqueness row (attribute_not_exists(SK)) in the same transaction; a repeat join cancels the transaction →409 Conflict. Removal deletes the uniqueness row in the same transaction. See Participants → USER uniqueness row. - Expense participants must exist. Every
ParticipantIdreferenced in an expense'sPaymentsor item splits must correspond to a row inParticipantsfor that group at write time. The API tier validates this by reading the relevant participant rows before the write. Deleting a participant who is referenced by an expense is rejected (409 Conflict); the OWNER must reassign or remove the references first. - Cascade on expense delete. Deleting an expense first queries
begins_with(SK, "EXPENSE#<eid>#")and then deletes all returned rows (head plus comments). When the row count fits, this runs as a singleTransactWriteItems(atomic). Otherwise it falls back toBatchWriteItem, which is not atomic: a partial failure can leave orphan comment rows. The implementation must (a) use idempotent deletes (no condition expression so a re-run is a no-op on already-deleted rows), and (b) retry on partial-batch responses (UnprocessedItems) until empty. A background sweeper that re-runs the cascade for any expense head missing itsMETArow but with surviving comment rows is recommended for defence-in-depth. - Identity uniqueness. Every
(provider, subject)pair maps to at most oneUserId. Enforced by the conditional create above. Linking an identity already attached to another user surfaces asConditionalCheckFailedException, which the API tier maps to409 Conflict. - Atomic signup. The provisioning Lambda writes the new profile row and its identity rows (the upstream
google#…/apple#…anchor plus thecognito#<sub>mapping) in a singleTransactWriteItemscall. A user always has at least one identity, and an identity always points at a real user.
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
- All tables use
RemovalPolicy.RETAINin CDK so accidental stack deletion does not drop data. tailspend-{stage}-prefixes isolate environments inside the same AWS account.- The
GroupDatatable mixes every per-group entity (head, invite, join requests, participants, expenses, comments, recurring templates) in a singleGroupIdpartition, distinguished by sort-key prefix andEntityType. Loading "the group" is oneQuery; loading "an expense and its thread" usesbegins_with(SK, "EXPENSE#<eid>#"); listing pending join requests uses the sparsePendingJoinRequestsIndex. - DynamoDB's 400 KB per-item ceiling now only applies to a single expense head or a single comment, both of which are small.
- Group + initial owner + invite are written transactionally in a single
TransactWriteItemscall across the same partition; participant add/remove and role change are single-row writes. - Approving a join request is one
TransactWriteItemsacross the JOINREQ row, the participant row, and the per-applicant lock row — all inGroupData. ForTAKEOVER, the participant'sParticipantIdis stable, so historical expenses are not rewritten. ForNEW, a freshParticipantIdis minted. - Decided join-request rows (APPROVED / DENIED / CANCELLED) are kept on the base table for audit but drop out of both GSIs because the
PendingKeyandUserPendingKeyattributes are removed on decision. A future cleanup sweep (or TTL pass) can prune them. - Deleting an expense uses a
Query(begins_with(SK, "EXPENSE#<eid>#")) followed by a batched delete of all returned rows. Deleting a group tombstones the head row first (UpdateItem SET Status = "DELETING"underattribute_exists(SK)) so the group is immediately unreadable — every read path (getGroupById,batchGetGroupsById) treats aDELETINGhead as not-found — then sweeps the rest of the partition with idempotent (unconditional) batched deletes and removes the head row last. The sweep is resumable: a mid-sweep crash leaves a group that still reads as gone, and the delete can be re-run to finish. This closes the previous non-atomic window where a partial sweep left a half-deleted group reading as live with orphaned participants still pointing at it on the GSI. - Hot-partition considerations. All rows for a single group share one partition: head + invite + participants (bounded) + expenses + comments + join requests + recurring templates. DynamoDB on-demand provides up to 1000 WCU / 3000 RCU per partition with adaptive capacity, which is comfortable for expected activity. If a group ever approaches those limits in practice, the partition can be split by sharding
GroupIdwith a small fan-out suffix; not needed today. - GSI projections.
GroupExpensesIndexandUserIdentitiesIndexprojectALLso list reads are one round-trip;UserParticipantsIndexprojectsINCLUDE (Role, ParticipantId)so the home-screen list renders without a second read;PendingJoinRequestsIndexandUserPendingJoinRequestsIndexprojectALLso the OWNER review UI and the applicant's "my pending requests" view both render without follow-up reads;ExpenseIdIndexprojectsKEYS_ONLYto keep storage and write amplification minimal.
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.