---
title: Architecture
description: How TALA is put together — two repositories, 14 backend modules, an exclusive user-or-organization ownership model, and a global request pipeline.
---

TALA has one architectural idea and everything else follows from it: **a row is owned by a person or by an organization — exactly one, never both, never neither.**

Assets carry `owner_user_id` alongside `organization_id`; collections carry the same pair as `user_id` and `organization_id`. Each column is individually nullable, but a database `CHECK` constraint on both tables requires precisely one of the pair to be set. That is what makes `?scope=personal|organization` work on the listing endpoints, and it is why every tenant-facing request resolves an org context before it touches the database.

```sql
CHK_assets_exactly_one_owner       num_nonnulls("owner_user_id", "organization_id") = 1
CHK_collections_exactly_one_owner  num_nonnulls("user_id", "organization_id") = 1
```

:::danger[The constraint is enforced by Postgres, not by a service]
Writing both columns, or neither, fails at the database with a check violation rather than a validation error — the global filter maps it to a generic 500. Any new write path must decide the owner up front. See [Data model](/architecture/data-model#ownership-is-exclusive).
:::

**[Modules](/architecture/modules)**

What each backend module owns and where it mounts.

**[Data model](/architecture/data-model)**

19 entities, 3 join tables, and the ownership chain.

**[Request pipeline](/architecture/request-pipeline)**

The guards, pipes, interceptors, and filter every request passes.

## The shape of a request

Every authenticated call to tenant data walks the same path.

1. **ThrottlerGuard**

    60 requests per 60 seconds per IP, applied globally as `APP_GUARD`.

2. **AuthGuard**

    Decrypts the AES-256-GCM envelope, verifies the JWT with `algorithms: ['HS256']` pinned, rejects any account that is not `VERIFIED`, and scrubs `password` off `req.user`.

3. **OrganizationGuard**

    Resolves the caller's active membership and writes `{ organizationId, role }` onto `req.orgContext`.

4. **ValidationPipe**

    Whitelists the body against its DTO — unknown keys are dropped silently — and coerces param and query types.

5. **Controller and service**

    The service re-checks ownership against the resolved context. A mismatch returns 404, not 403.

6. **ActivityLogsInterceptor**

    Records the call after the response via `tap()`, without awaiting.

## Two audit trails, deliberately

They answer different questions and neither replaces the other.

| | `asset_logs` | `activity_logs` |
| --- | --- | --- |
| Written by | services, inside the transaction | `ActivityLogsInterceptor`, after the response |
| Awaited | yes | no |
| Has HTTP context | no | endpoint, status, IP, user agent, duration |
| Survives a rollback | no — it rolls back with the write | yes — it is fire-and-forget |
| Answers | "what happened to this asset?" | "who called what, and did it work?" |

## Identifiers

IDs are CSPRNG-generated, not sequential. `generateId(prefix, suffixLength = 12)` in `src/lib/utils/id.util.ts` produces values like `ASa7Bk9x2Q…`.

The old scheme was `PREFIX + (1000 + COUNT + 1)`, which both raced under concurrency and let anyone enumerate the table. It is gone — but legacy sequential IDs still exist in old rows.

:::warning[Two ID formats coexist]
Anything that parses an ID must accept both formats. Lengths vary by entity: users, collections, organizations, members, `asset_logs` and `organization_invitations` are 10 characters; projects, tasks, and comments are 12; favourites are 14; `refresh_tokens.jti` is 36 and `oauth_exchange_codes.code_hash` is 64. The rest are plain `varchar` — see [Data model](/architecture/data-model) for the full table.
:::
