Ctrl K
ComunidadGuías…

docs/database.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
```
╔════════════════════════════════════════════════════════════════════════════╗
║                                                                            ║
║   DATABASE                                                                 ║
║   MySQL 8 schema: 28 tables modelling the workspace → log hierarchy.       ║
║                                                                            ║
╚════════════════════════════════════════════════════════════════════════════╝
```

# Database

Cloud Codex uses **MySQL 8** running inside Docker. The schema is initialized
via [`init.sql`](../init.sql), with incremental upgrades for live deployments
in [`migrations/`](../migrations/). Optional seed data is in
[`seed.sql`](../seed.sql).

## Connection

```bash
docker compose up -d
mysql -h 127.0.0.1 -P 3306 -u <DB_USER> -p <DB_NAME>
```

See `.env.example` for the required environment variables (`DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`).

---

## Schema Overview

The database models a **workspace → squad → archive → log** hierarchy with layered access control at every level.

```
   workspaces
     └── squads
           ├── squad_members
           ├── squad_invitations
           └── archives
                 ├── archive_repos          (GitHub bulk-import link)
                 └── logs
                       ├── versions
                       ├── doc_images       (which images it holds)
                       ├── comments
                       │     └── comment_replies
                       ├── github_links     (per-doc file sync)
                       ├── github_embed_refs
                       ├── github_pr_sessions
                       └── favorites

   users  ──< sessions / oauth_accounts / permissions
          ──< user_invitations / password_reset_tokens / two_factor_codes
          ──< notifications  ──┐
          ──< watches  ────────┤  fan-out paths from
                               └─ activity_log ── webhook_events (outbox)

   webhook_subscriptions ──< webhook_deliveries >── webhook_events
```

---

## Tables

### `workspaces`

Top-level organizational container, owned by a single user. Workspaces can only be created by admins.

| Column       | Type                    | Notes                              |
|--------------|-------------------------|------------------------------------|
| `id`         | INT AUTO_INCREMENT PK   |                                    |
| `name`       | TEXT NOT NULL           | Display name                       |
| `owner_id`   | INT NULL                | FK → `users(id)` ON DELETE SET NULL. Nullable so deleting a user leaves the workspace ownerless rather than deleting it |
| `created_at` | TIMESTAMP               | Defaults to current timestamp      |

**Relationships:** Has many `squads`.

---

### `users`

Registered user accounts. User creation is invite-only (see `user_invitations`).

| Column               | Type                                 | Notes                                        |
|----------------------|--------------------------------------|----------------------------------------------|
| `id`                 | INT AUTO_INCREMENT PK                |                                              |
| `name`               | VARCHAR(32) UNIQUE NOT NULL          | Username; alphanumeric + underscores only    |
| `email`              | VARCHAR(255) UNIQUE NOT NULL         |                                              |
| `password_hash`      | TEXT                                 | bcrypt hash; NULL for OAuth-only accounts    |
| `avatar_url`         | VARCHAR(512)                         | Relative path to uploaded avatar             |
| `two_factor_method`  | ENUM('none', 'email', 'totp')        | Defaults to `'none'`                         |
| `totp_secret`        | VARCHAR(64)                          | TOTP shared secret (only set when TOTP is enabled) |
| `is_admin`           | BOOLEAN                              | Super-admin flag; synced from `.env` on startup |
| `notification_prefs` | JSON                                 | Per-type email opt-in/out (NULL = use defaults from `services/notifications.js`) |
| `created_at`         | TIMESTAMP                            |                                              |

**Relationships:** Has many `sessions`, `oauth_accounts`, `permissions`, `squad_members`, `notifications`, `watches`.

---

### `oauth_accounts`

Links a Cloud Codex user to an external OAuth provider (Google or GitHub).

| Column              | Type                          | Notes                                              |
|---------------------|-------------------------------|----------------------------------------------------|
| `id`                | INT AUTO_INCREMENT PK         |                                                    |
| `user_id`           | INT FK → users                | ON DELETE CASCADE                                  |
| `provider`          | ENUM('google', 'github')      |                                                    |
| `provider_user_id`  | VARCHAR(255)                  | ID from the OAuth provider                        |
| `provider_email`    | VARCHAR(255)                  | Email returned by the provider                    |
| `provider_username` | VARCHAR(255)                  | GitHub login; matched by Squad-to-Team sync       |
| `provider_avatar_url` | VARCHAR(500)                | Provider avatar                                   |
| `encrypted_token`   | TEXT                          | AES-256-GCM encrypted access token (GitHub only)  |
| `token_status`      | ENUM('active','revoked','unknown') | `revoked` once GitHub refuses the token      |
| `created_at`        | TIMESTAMP                     |                                                    |

**Unique constraints:**

- `uq_provider_user (provider, provider_user_id)`: a provider account belongs to at most one user.
- `uq_oauth_user_provider (user_id, provider)`: a user holds at most one account per provider.
  Google sign-in also checks this before it links a user matched by email, and answers a second
  Google account with `identity_conflict`; the key is what refuses the second of two sign-ins that
  pass that check at the same instant, and the answer is the same (`docs/maps/open-questions.md`
  C7). Added by `migrations/2026-09-25-oauth-one-link-per-provider.sql`, which refuses to run on
  an install that already holds a double link; the CHANGELOG's Migration section says how to
  resolve one.

---

### `sessions`

Active login sessions, one row per sign-in. Tokens are 64-character cryptographically random strings, and only their SHA-256 digest is stored.

| Column           | Type            | Notes                                  |
|------------------|-----------------|----------------------------------------|
| `id`             | CHAR(64) PK     | SHA-256 of the session token, lowercase hex; never the token itself |
| `user_id`        | INT FK → users  | ON DELETE CASCADE                      |
| `auth_provider`  | VARCHAR(16)     | The flow that minted it: `local` or `google` (CHECK). NOT NULL with no default |
| `ip_address`     | VARCHAR(45)     | IPv4 or IPv6                           |
| `user_agent`     | TEXT            |                                        |
| `created_at`     | TIMESTAMP       |                                        |
| `last_active_at` | TIMESTAMP       | Updated on each authenticated request |
| `expires_at`     | TIMESTAMP       | 7 days after sign-in, never extended   |

**Indexes:** `user_id`, `expires_at`. **Constraints:** `chk_sessions_auth_provider`.

> Every sign-in inserts its own row, so each device has its own session and signing out of one leaves the others signed in. Nothing refreshes a row in place; expired rows are deleted by a daily prune in `server.js`. Added by `migrations/2026-09-27-session-per-sign-in.sql`, which also hashed the rows already stored.

---

### `password_reset_tokens`

Single-use short-lived tokens. Five flows share this table, so it is not only
password resets.

| Column       | Type            | Notes                              |
|--------------|-----------------|------------------------------------|
| `id`         | INT AUTO_INCREMENT PK |                              |
| `user_id`    | INT FK → users  | ON DELETE CASCADE                  |
| `token`      | CHAR(64) UNIQUE | Cryptographically random           |
| `purpose`    | VARCHAR(32) NOT NULL | Which flow minted the row, CHECK-constrained |
| `new_email`  | VARCHAR(255) NULL | The address an `email_change` row confirms; NULL on every other purpose |
| `expires_at` | TIMESTAMP       | 10 minutes to 1 hour by flow       |
| `used`       | BOOLEAN         | Marked TRUE once consumed          |
| `created_at` | TIMESTAMP       |                                    |

`purpose` is one of `password_reset`, `two_factor_login`, `totp_setup`,
`two_factor_disable`, `email_change`, enforced by a `CHECK` constraint and mirrored by
`TOKEN_PURPOSE` in `cloudcodex/routes/helpers/shared.js`. It has **no
`DEFAULT`**, deliberately: a new flow that forgets to name its purpose fails at
insert (error 1364) instead of silently minting a password reset token, and a
value outside the set fails too (error 3819). It is `VARCHAR` plus `CHECK`
rather than `ENUM` for exactly that reason: MySQL gives a `NOT NULL` `ENUM`
with no `DEFAULT` an implicit default of the first listed value even under
`STRICT_TRANS_TABLES`, so an omitted purpose would silently become
`password_reset`. Every reader of this table constrains on `purpose`, so a
token minted by one flow is not accepted by another.

`chk_password_reset_tokens_new_email`, `CHECK ((purpose = 'email_change') =
(new_email IS NOT NULL))`, requires an address on every `email_change` row and
refuses one on any other. The email-change confirmation applies exactly that
address, so the code emailed for one address cannot move the account to
another.

---

### `two_factor_codes`

Short-lived 6-digit codes sent by email for the email-based 2FA flow.

| Column       | Type            | Notes                              |
|--------------|-----------------|------------------------------------|
| `id`         | INT AUTO_INCREMENT PK |                              |
| `user_id`    | INT FK → users  | ON DELETE CASCADE                  |
| `code`       | CHAR(6)         | Numeric OTP                        |
| `expires_at` | TIMESTAMP       | Short window (e.g. 10 min)         |
| `used`       | BOOLEAN         | Marked TRUE once verified          |
| `created_at` | TIMESTAMP       |                                    |

---

### `user_invitations`

Admin-issued email invitations required for new account creation.

| Column       | Type                    | Notes                                             |
|--------------|-------------------------|---------------------------------------------------|
| `id`         | INT AUTO_INCREMENT PK   |                                                   |
| `email`      | VARCHAR(255)            | Target email; must match at signup                |
| `token`      | CHAR(64) UNIQUE         | Included in the invitation link                   |
| `invited_by` | INT FK → users          | ON DELETE CASCADE                                 |
| `expires_at` | TIMESTAMP               | 7-day expiry                                      |
| `accepted`   | BOOLEAN                 | Marked TRUE when account is created               |
| `created_at` | TIMESTAMP               |                                                   |

---

### `squads`

A team or group within a workspace. Squads own archives.

| Column         | Type                    | Notes                           |
|----------------|-------------------------|---------------------------------|
| `id`           | INT AUTO_INCREMENT PK   |                                 |
| `workspace_id` | INT FK → workspaces     | ON DELETE CASCADE               |
| `name`         | TEXT NOT NULL           |                                 |
| `created_at`   | TIMESTAMP               |                                 |
| `created_by`   | INT FK → users          | ON DELETE SET NULL              |

**Relationships:** Has many `squad_members` and `archives`.

---

### `permissions`

Global per-user permissions for top-level actions. One row per user.

| Column            | Type            | Notes                                         |
|-------------------|-----------------|-----------------------------------------------|
| `id`              | INT AUTO_INCREMENT PK |                                         |
| `user_id`         | INT FK → users  | ON DELETE CASCADE                             |
| `create_squad`    | BOOLEAN         | Can create squads in any workspace            |
| `create_archive`  | BOOLEAN         | Can create archives in any squad              |
| `create_log`      | BOOLEAN         | Defaults to TRUE — can create documents       |

> Admins bypass all permission checks. Workspace owners also bypass permission checks within their workspace.

---

### `squad_members`

Member roster for a squad with granular per-member permission flags.

| Column                | Type                                  | Notes                                |
|-----------------------|---------------------------------------|--------------------------------------|
| `id`                  | INT AUTO_INCREMENT PK                 |                                      |
| `squad_id`            | INT FK → squads                       | ON DELETE CASCADE                    |
| `user_id`             | INT FK → users                        | ON DELETE CASCADE                    |
| `role`                | ENUM('member', 'admin', 'owner')      | `owner` has implicit full access     |
| `can_read`            | BOOLEAN                               | Can view archives                    |
| `can_write`           | BOOLEAN                               | Can edit documents                   |
| `can_create_log`      | BOOLEAN                               | Can create new documents             |
| `can_create_archive`  | BOOLEAN                               | Can create new archives              |
| `can_manage_members`  | BOOLEAN                               | Can invite/remove squad members      |
| `can_delete_version`  | BOOLEAN                               | Can delete published versions        |
| `can_publish`         | BOOLEAN                               | Can publish new versions             |
| `joined_at`           | TIMESTAMP                             |                                      |

**Unique constraint:** `(squad_id, user_id)`.

---

### `squad_invitations`

Pending/historical invitations to join a squad. Mirrors the permission flags of `squad_members`.

| Column              | Type                                        | Notes                  |
|---------------------|---------------------------------------------|------------------------|
| `id`                | INT AUTO_INCREMENT PK                       |                        |
| `squad_id`          | INT FK → squads                             | ON DELETE CASCADE      |
| `invited_by`        | INT FK → users                              | ON DELETE CASCADE      |
| `invited_user_id`   | INT FK → users                              | ON DELETE CASCADE      |
| `role`              | ENUM('member', 'admin', 'owner')            |                        |
| `can_read` … `can_publish` | BOOLEAN columns (same as squad_members) |               |
| `status`            | ENUM('pending', 'accepted', 'declined')     | Defaults to `'pending'`|
| `created_at`        | TIMESTAMP                                   |                        |
| `responded_at`      | TIMESTAMP NULL                              | Set when user responds |

**Unique constraint:** `(squad_id, invited_user_id, status)` — prevents duplicate pending invites.

---

### `archives`

A collection of related documents. Archives are the primary organizational unit for content, belonging to a squad.

| Column                   | Type              | Notes                                                           |
|--------------------------|-------------------|-----------------------------------------------------------------|
| `id`                     | INT AUTO_INCREMENT PK |                                                             |
| `squad_id`               | INT FK → squads   | ON DELETE SET NULL; NULL for personal/standalone archives       |
| `name`                   | TEXT NOT NULL     |                                                                 |
| `created_at`             | TIMESTAMP         |                                                                 |
| `created_by`             | INT FK → users    | ON DELETE SET NULL                                              |
| `read_access`            | JSON ARRAY        | User IDs who can read                                           |
| `write_access`           | JSON ARRAY        | User IDs who can write                                          |
| `read_access_squads`     | JSON ARRAY        | Squad IDs whose members inherit read access                     |
| `write_access_squads`    | JSON ARRAY        | Squad IDs whose members inherit write access                    |
| `read_access_workspace`  | BOOLEAN           | If TRUE, all members of the parent workspace can read           |
| `write_access_workspace` | BOOLEAN           | If TRUE, all members of the parent workspace can write          |

> Access is layered: direct user grants → squad membership grants → workspace-wide flag. Archive creators and squad/workspace owners always have full access regardless of the JSON arrays. See [Access Control](./access-control.md) for full details.

---

### `archive_repos`

Links a GitHub repository to an archive for the GitHub integration feature.

| Column          | Type               | Notes                           |
|-----------------|--------------------|---------------------------------|
| `id`            | INT AUTO_INCREMENT PK |                              |
| `archive_id`    | INT FK → archives  | ON DELETE CASCADE               |
| `repo_full_name`| VARCHAR(255)       | e.g. `owner/repo-name`          |
| `repo_owner`    | VARCHAR(255)       |                                 |
| `repo_name`     | VARCHAR(255)       |                                 |
| `linked_by`     | INT FK → users     | ON DELETE CASCADE               |
| `linked_at`     | TIMESTAMP          |                                 |

**Unique constraint:** `(archive_id, repo_full_name)`.

---

### `logs`

An individual document (referred to as a "log" in the data model). This is the core content entity.

| Column              | Type                | Notes                                                                 |
|---------------------|---------------------|-----------------------------------------------------------------------|
| `id`                | INT AUTO_INCREMENT PK |                                                                     |
| `archive_id`        | INT FK → archives   | ON DELETE CASCADE                                                     |
| `title`             | TEXT NOT NULL       |                                                                       |
| `html_content`      | MEDIUMTEXT          | Rendered HTML from the editor; sanitized before storage               |
| `markdown_content`  | MEDIUMTEXT          | Optional markdown source; NULL when rich text is the canonical format |
| `ydoc_state`        | LONGBLOB            | Binary Yjs CRDT state for real-time collaboration                     |
| `plain_content`     | MEDIUMTEXT (GENERATED) | Strips HTML tags from `html_content`; stored for full-text search  |
| `parent_id`         | INT FK → logs       | ON DELETE SET NULL; used to nest logs into a tree                     |
| `created_at`        | TIMESTAMP           |                                                                       |
| `created_by`        | INT FK → users      | ON DELETE SET NULL                                                    |
| `updated_at`        | TIMESTAMP           | Auto-updated on every change                                          |
| `updated_by`        | INT FK → users      | ON DELETE SET NULL                                                    |
| `version`           | INT DEFAULT 0       | Incremented each time a version is published                          |
| `read_access`       | JSON ARRAY          | Per-log read grants (user IDs); supplemental to archive-level access  |
| `write_access`      | JSON ARRAY          | Per-log write grants                                                  |

**Full-text index:** `FULLTEXT (title, plain_content)` — used by the search API for fast, relevance-ranked queries.

> `ydoc_state` is written by the collaborative editing service every few seconds while a document is being actively edited. `html_content` is written on explicit save or publish events.

---

### `github_links`

Links a specific GitHub file to a log, enabling live file sync. One link per log.

| Column        | Type               | Notes                                          |
|---------------|--------------------|------------------------------------------------|
| `id`          | INT AUTO_INCREMENT PK |                                             |
| `log_id`      | INT FK → logs      | ON DELETE CASCADE; UNIQUE (one link per log)   |
| `repo_owner`  | VARCHAR(255)       |                                                |
| `repo_name`   | VARCHAR(255)       |                                                |
| `file_path`   | VARCHAR(500)       | Path within the repo (e.g. `docs/guide.md`)   |
| `branch`      | VARCHAR(255)       |                                                |
| `file_sha`    | VARCHAR(64)        | Last known SHA from GitHub; used for edit checks |
| `linked_by`   | INT FK → users     | ON DELETE CASCADE                              |
| `linked_at`   | TIMESTAMP          |                                                |
| `updated_at`  | TIMESTAMP          |                                                |

---

### `versions`

Published version snapshots of a document's HTML content.

| Column         | Type               | Notes                                           |
|----------------|--------------------|-------------------------------------------------|
| `id`           | INT AUTO_INCREMENT PK |                                              |
| `log_id`       | INT FK → logs      | ON DELETE CASCADE                               |
| `version`      | INT NOT NULL       | Monotonically increasing version number         |
| `title`        | VARCHAR(255)       | Optional human-readable version label           |
| `notes`        | TEXT               | Optional release notes (up to 5000 chars)       |
| `html_content` | MEDIUMTEXT         | Snapshot of document HTML at publish time       |
| `created_at`   | TIMESTAMP          |                                                 |
| `created_by`   | INT FK → users     | ON DELETE SET NULL                              |
| `read_access`  | JSON ARRAY         | Future use; currently mirrors the parent log    |

---

### `doc_images`

Which documents hold which stored image. The `/doc-images` handler serves an
image only to its uploader and to users who can read a document named here
(see `docs/security.md`, Document Images).

| Column        | Type             | Notes                                                        |
|---------------|------------------|--------------------------------------------------------------|
| `hash`        | CHAR(16) NOT NULL | The image file's name without `.webp`                       |
| `log_id`      | INT FK → logs    | ON DELETE CASCADE                                            |
| `uploaded_by` | INT FK → users   | ON DELETE SET NULL; set only when this row's writer supplied the image's bytes |
| `created_at`  | TIMESTAMP        |                                                              |

**Primary key:** `(hash, log_id)`. **Index:** `idx_doc_images_log (log_id)`.
Written by the upload route, by a document write that adds a reference (only
from a writer who can see the image), and once by `npm run backfill:doc-images`
on an install upgraded to it.

---

### `comments`

Threaded annotations on a document, optionally anchored to a text selection.

| Column           | Type                                                    | Notes                                |
|------------------|---------------------------------------------------------|--------------------------------------|
| `id`             | INT AUTO_INCREMENT PK                                   |                                      |
| `log_id`         | INT FK → logs                                           | ON DELETE CASCADE                    |
| `user_id`        | INT FK → users                                          | ON DELETE CASCADE                    |
| `content`        | TEXT NOT NULL                                           | Up to 10,000 characters              |
| `tag`            | ENUM('comment', 'suggestion', 'question', 'issue', 'note') | Defaults to `'comment'`           |
| `status`         | ENUM('open', 'resolved', 'dismissed')                   | Defaults to `'open'`                 |
| `selection_start`| INT                                                     | Character offset anchor start        |
| `selection_end`  | INT                                                     | Character offset anchor end          |
| `selected_text`  | TEXT                                                    | Copy of the selected text (up to 500 chars) |
| `resolved_by`    | INT FK → users                                          | ON DELETE SET NULL                   |
| `resolved_at`    | TIMESTAMP NULL                                          |                                      |
| `created_at`     | TIMESTAMP                                               |                                      |
| `updated_at`     | TIMESTAMP                                               |                                      |

**Indexes:** `(log_id)`, `(log_id, status)`.

---

### `comment_replies`

Replies to a top-level comment. One level of nesting only.

| Column       | Type               | Notes                        |
|--------------|--------------------|------------------------------|
| `id`         | INT AUTO_INCREMENT PK |                           |
| `comment_id` | INT FK → comments  | ON DELETE CASCADE            |
| `user_id`    | INT FK → users     | ON DELETE CASCADE            |
| `content`    | TEXT NOT NULL       |                              |
| `created_at` | TIMESTAMP           |                              |
| `updated_at` | TIMESTAMP           |                              |

**Index:** `(comment_id)`.

---

### `favorites`

Per-user bookmark list for quick access to logs.

| Column       | Type              | Notes                        |
|--------------|-------------------|------------------------------|
| `id`         | INT AUTO_INCREMENT PK |                          |
| `user_id`    | INT FK → users    | ON DELETE CASCADE            |
| `log_id`     | INT FK → logs     | ON DELETE CASCADE            |
| `created_at` | TIMESTAMP         |                              |

**Unique constraint:** `(user_id, log_id)`.

---

### `notifications`

Inbox entries surfaced to a single user. Created by
`services/notifications.js` for every user-facing alert (mention, comment on
my doc, watched-doc activity, squad invite). Self-suppressed and coalesced
within a 60-second window per (user, type, resource).

| Column          | Type                  | Notes                                              |
|-----------------|-----------------------|----------------------------------------------------|
| `id`            | INT AUTO_INCREMENT PK |                                                    |
| `user_id`       | INT FK → users        | ON DELETE CASCADE; recipient                       |
| `type`          | VARCHAR(50)           | e.g. `mention`, `comment_on_my_doc`, `watched_publish` |
| `actor_id`      | INT FK → users        | ON DELETE SET NULL; who caused it (NULL for system)|
| `title`         | VARCHAR(255)          | Sanitized + plain-text                             |
| `body`          | TEXT                  | Sanitized; up to 2000 chars                        |
| `link_url`      | VARCHAR(512)          | Where clicking the notification goes               |
| `resource_type` | VARCHAR(30)           | e.g. `log`, `comment`, `version`, `squad`          |
| `resource_id`   | INT                   | Used for the coalesce key                          |
| `metadata`      | JSON                  | Free-form per-type detail                          |
| `read_at`       | TIMESTAMP NULL        | NULL until marked read                             |
| `created_at`    | TIMESTAMP             |                                                    |

**Indexes:** `(user_id, created_at)`, `(user_id, read_at, created_at)`,
`(resource_type, resource_id)`.

---

### `watches`

Subscription rows linking a user to a log or archive. Activity events on a
watched resource fan out to per-user `notifications` rows. Created manually
from the document UI or automatically (e.g. when a user is mentioned).

| Column          | Type                  | Notes                                  |
|-----------------|-----------------------|----------------------------------------|
| `id`            | INT AUTO_INCREMENT PK |                                        |
| `user_id`       | INT FK → users        | ON DELETE CASCADE                      |
| `resource_type` | VARCHAR(30)           | `'log'` or `'archive'`                 |
| `resource_id`   | INT                   |                                        |
| `source`        | VARCHAR(20)           | `'manual'` or `'auto'` (e.g. `'mention'`) |
| `created_at`    | TIMESTAMP             |                                        |

**Unique constraint:** `(user_id, resource_type, resource_id)` — one row
per (user, resource).
**Index:** `(resource_type, resource_id)` — fast fan-out lookups.

---

### `activity_log`

Workspace-scoped chronological feed of meaningful events: documents
created/published/restored, comments added/resolved, archive grants
changed, squad memberships moved. Read access is filtered at query time
via `routes/helpers/ownership.js`. Auto-pruned daily by `server.js` —
entries older than 365 days are removed.

| Column          | Type                  | Notes                                       |
|-----------------|-----------------------|---------------------------------------------|
| `id`            | BIGINT AUTO_INCREMENT PK |                                          |
| `workspace_id`  | INT                   | Always present                              |
| `squad_id`      | INT                   | Optional                                    |
| `user_id`       | INT FK → users        | ON DELETE CASCADE; actor                    |
| `action`        | VARCHAR(50)           | e.g. `log.published`, `comment.resolved`    |
| `resource_type` | VARCHAR(30)           | `log`, `archive`, `comment`, `version`, `squad` |
| `resource_id`   | INT                   |                                             |
| `metadata`      | JSON                  | Free-form per-action detail                 |
| `created_at`    | TIMESTAMP             |                                             |

**Indexes:** `(workspace_id, created_at)`, `(squad_id, created_at)`,
`(resource_type, resource_id, created_at)`, `(user_id, created_at)`.

---

### `webhook_subscriptions`

Who receives outbound events ([api/webhooks.md](api/webhooks.md)). Rows are made
by an instance admin through `/api/admin/webhooks` (`source = 'admin'`). The
`source = 'env'` rows that the environment once declared were deleted by the
0.15.0 migration, and the worker never sends a row that is not `admin`.

| Column                 | Type                  | Notes                                       |
|------------------------|-----------------------|---------------------------------------------|
| `id`                   | INT AUTO_INCREMENT PK |                                             |
| `url`                  | VARCHAR(2048)         | The receiver, checked by the SSRF guard     |
| `secret`               | VARCHAR(255)          | Admin rows only, never returned by the API |
| `source`               | VARCHAR(8)            | `env` (legacy, unsent) or `admin` (CHECK)   |
| `enabled`              | BOOLEAN               | Default TRUE                                |
| `event_types`          | JSON                  | `NULL` = every emitted type, else an array  |
| `workspace_id`         | INT                   | `NULL` = every workspace. No FK, on purpose |
| `disabled_reason`      | VARCHAR(255)          | Why it is off, for the admin console        |
| `consecutive_failures` | INT                   | Backoff state for the delivery worker       |
| `paused_until`         | DATETIME(3)           | Backoff state for the delivery worker       |
| `created_by`           | INT FK → users        | ON DELETE SET NULL                          |
| `created_at`           | TIMESTAMP             |                                             |

**Constraints:** `chk_webhook_subscriptions_source`, and
`chk_webhook_subscriptions_secret`, `(source = 'env') = (secret IS NULL)`.

### `webhook_events`

The outbox: one row per emitted event, written only when a subscription
matches it. `body` is the exact bytes every delivery of it sends.

| Column        | Type                     | Notes                                      |
|---------------|--------------------------|--------------------------------------------|
| `id`          | BIGINT AUTO_INCREMENT PK | The envelope's `sequence`                  |
| `event_uuid`  | CHAR(36)                 | The envelope's `id`, unique; the idempotency key |
| `type`        | VARCHAR(32)              | One of the eight emitted actions           |
| `workspace_id`| INT                      | The event's workspace                      |
| `occurred_at` | DATETIME(3)              | UTC                                        |
| `body`        | MEDIUMBLOB               | The serialized envelope, UTF-8             |
| `created_at`  | TIMESTAMP                | Indexed                                    |

### `webhook_deliveries`

One row per (subscription, event), worked in event order per subscription by
the delivery worker (`services/webhook-worker.js`). `attempts` counts failed sends.

| Column             | Type                     | Notes                                    |
|--------------------|--------------------------|------------------------------------------|
| `id`               | BIGINT AUTO_INCREMENT PK | Sent as `X-Codex-Delivery`               |
| `subscription_id`  | INT FK → webhook_subscriptions | ON DELETE CASCADE                  |
| `event_id`         | BIGINT FK → webhook_events | ON DELETE CASCADE                      |
| `status`           | VARCHAR(12)              | `pending`, `delivered` or `dead` (CHECK) |
| `attempts`         | INT                      |                                          |
| `leased_by`, `lease_expires_at` | CHAR(36), DATETIME(3) | The worker's lease               |
| `last_status`, `last_error` | SMALLINT, VARCHAR(255) | The last attempt's outcome       |
| `delivered_at`     | DATETIME(3)              |                                          |
| `created_at`       | TIMESTAMP                |                                          |

**Indexes:** `UNIQUE (subscription_id, event_id)`,
`(subscription_id, status, event_id)` for the head of each queue.

---

### `github_embed_refs`

Tracks every GitHub embed (code snippet, issue, pull request, file) that
appears inside a log. Each row stores a pinned `ref_value` (and pinned SHA
where applicable) so the embed remains stable even if the source moves.

| Column         | Type                    | Notes                                      |
|----------------|-------------------------|--------------------------------------------|
| `id`           | INT AUTO_INCREMENT PK   |                                            |
| `log_id`       | INT FK → logs           | ON DELETE CASCADE                          |
| `embed_type`   | ENUM('code','issue','pr','file') |                                   |
| `repo_owner`   | VARCHAR(255)            |                                            |
| `repo_name`    | VARCHAR(255)            |                                            |
| `ref_value`    | VARCHAR(500)            | File path / issue number / PR number / SHA |
| `pinned_sha`   | VARCHAR(64)             | Optional commit SHA the embed is pinned to |
| `branch`       | VARCHAR(255)            | Branch the embed was captured from         |
| `last_seen_at` | TIMESTAMP NULL          | Updated when the embed is rendered         |
| `created_at`   | TIMESTAMP               |                                            |

**Indexes:** `(log_id)`, `(repo_owner, repo_name, embed_type)`.

---

### `github_pr_sessions`

A long-lived session per GitHub PR that the team is collaborating on
through Cloud Codex. Backs the `GitHubMergeDialog` UI and links PR-side
conversation back to any documents tied to the changed files.

| Column        | Type                  | Notes                                |
|---------------|-----------------------|--------------------------------------|
| `id`          | INT AUTO_INCREMENT PK |                                      |
| `log_id`      | INT FK → logs         | ON DELETE CASCADE                    |
| `repo_owner`  | VARCHAR(255)          |                                      |
| `repo_name`   | VARCHAR(255)          |                                      |
| `pr_number`   | INT                   |                                      |
| `opened_by`   | INT FK → users        | ON DELETE CASCADE                    |
| `opened_at`   | TIMESTAMP             |                                      |

**Unique constraint:** `(repo_owner, repo_name, pr_number)`.
**Index:** `(log_id)`.

---


### `webhook_sequence_lock`

One row, `id = 1` (`CHECK (id = 1)`), that holds no data. `emitEvent` takes it
`FOR UPDATE` as the first statement of its transaction, so the outbox ids it
allocates after that point are allocated in commit order. Created by
`migrations/2026-10-10-webhook-sequence-lock.sql` and by `init.sql`.

## Entity Relationship Diagram

```
   workspaces ──< squads ──< archives ──< logs ──< versions
                    │            │           │
                    ├──< squad_members (users)
                    ├──< squad_invitations (users)
                    └──< archive_repos

   logs ──< comments ──< comment_replies
        ──< favorites
        ──< github_links              (per-doc file sync)
        ──< github_embed_refs         (code/issue/PR/file embeds)
        ──< github_pr_sessions        (long-lived PR session state)

   users ──< sessions
         ──< oauth_accounts
         ──< permissions
         ──< password_reset_tokens
         ──< two_factor_codes
         ──< user_invitations
         ──< notifications            (per-user inbox)
         ──< watches                  (per-user subscriptions)

   activity_log ─► (workspace, squad, user, resource_type+id)
                    no FK on workspace/squad/resource — pruned daily
```

---

## Migrations

`init.sql` is the canonical schema (used by fresh installs and by tests).
Live deployments apply incremental upgrades from the `migrations/`
directory with `npm run migrate`. The runner records each applied file in
`schema_migrations` (filename, sha256, elapsed ms, timestamp), so re-running is
a no-op and an edited applied file is a hard stop. Every database needs one
adoption command before its first ordinary run: `--adopt-fresh-install` if
`init.sql` just built it, `--baseline` if it predates the runner. See
[deployment.md](deployment.md#upgrades) for which, and for where to run it on
each compose file. The current set:

| File                            | Adds                                                |
|---------------------------------|-----------------------------------------------------|
| `add_github_links.sql`          | `github_links` table                                |
| `add_markdown_content.sql`      | `logs.markdown_content` column                      |
| `add_activity_log.sql`          | `activity_log` table                                |
| `add_watches.sql`               | `watches` table                                     |
| `add_notifications.sql`         | `notifications` table + `users.notification_prefs`  |
| `p0_github_sync.sql`            | GitHub sync state on `github_links`                 |
| `p1_github_embeds.sql`          | `github_embed_refs` table                           |
| `p2_github_collab.sql`          | `github_pr_sessions` table                          |
| `p3_github_polish.sql`          | indexes / column tweaks for the GitHub stack        |
| `add_first_run.sql`             | squad grant columns on `user_invitations` + `users.onboarded_at` |
| `add_workspace_owner_id.sql`    | `workspaces.owner_id` FK, replacing the `owner` email column |
| `drop_squad_permissions.sql`    | drops the unenforced `squad_permissions` table      |
| `widen_log_content.sql`         | `MEDIUMTEXT` for the three document content columns |
| `2026-09-08-token-purpose.sql`  | `password_reset_tokens.purpose` + its `CHECK`       |
| `2026-09-25-oauth-one-link-per-provider.sql` | `UNIQUE (user_id, provider)` on `oauth_accounts` |
| `2026-09-25-token-purpose-email-change.sql` | `password_reset_tokens.new_email`, `email_change` in the purpose `CHECK`, and `chk_password_reset_tokens_new_email` |
| `2026-09-27-session-per-sign-in.sql` | `sessions.auth_provider` + its `CHECK`, and hashes the stored session ids |
| `2026-09-27-who-may-see-doc-images.sql` | `doc_images` table (then run `npm run backfill:doc-images` once) |
| `2026-09-28-webhooks.sql` | `webhook_subscriptions`, `webhook_events` and `webhook_deliveries` tables |
| `2026-10-10-webhook-sequence-lock.sql` | `webhook_sequence_lock`, the one-row table an emit locks so outbox ids are allocated in commit order |

> **Rule:** any column or table added as a migration must also be present
> in `init.sql`. Both must stay in sync — fresh installs and existing
> deployments must converge to the same schema.
>
> `schema_migrations` is the one exception: it is runner-owned bookkeeping and
> is deliberately **not** in `init.sql`. A fresh install that already had the
> table would read "nothing applied" and try to replay every delta above
> against the schema they are already folded into.

---

## Key Design Notes

- **Invite-only signups:** New users must have a matching `user_invitations` token. No public registration.
- **JSON access arrays:** `read_access` and `write_access` on `archives` store arrays of user IDs. This avoids a separate join table for common cases and keeps access queries compact.
- **Access cascade:** A workspace owner, squad owner, or archive creator always has full access — access arrays are supplemental grants, not the only mechanism. See the [access control doc](./access-control.md) for full resolution order.
- **Generated column for search:** `plain_content` on `logs` is a STORED generated column that strips HTML. The `FULLTEXT` index runs on this column plus `title`, enabling boolean mode relevance-ranked search without a separate search service.
- **Dual document state:** A log has `html_content` (human-readable, set on explicit save) and `ydoc_state` (binary Yjs CRDT, written frequently during live editing). When a user opens a document, the Tiptap editor hydrates from `html_content`; ongoing edits propagate as CRDT updates via WebSocket.
- **Soft parent references:** `logs.parent_id` self-references to form a document tree within an archive. Deleting a parent sets children's `parent_id` to NULL (ON DELETE SET NULL) rather than cascading deletes.