Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions IMPLEMENTATION_DOCS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Implementation docs

Local index of the documents that track what this codebase actually implements.
Several docs under `docs/` and `apps/*/docs/` state "cross-referenced from
`IMPLEMENTATION_DOCS.md`" — this is the file they mean.

Note: this file is listed in `.gitignore`, so it normally lives only in the
maintainer's working tree. It is committed here so the ADR index has a stable
cross-link from a repo-root document; if it should stay local, paste the
"Decisions" block below into your own copy and drop the file.

## Decisions

- [docs/adr/README.md](./docs/adr/README.md) — Architecture Decision Records:
the index, the template, and the backfilled decisions (one `devices` table,
`(createdAt, id)` ordering, ciphertext-only storage, per-device envelopes, the
sealed-box → Signal migration path, MLS for groups), plus the two decisions
that were shipped and superseded.

## Architecture and operations

- [docs/threat-model.md](./docs/threat-model.md) — what the gateway and its
stores can see, and the trust boundary.
- [docs/runbook.md](./docs/runbook.md) — scaling, failure modes, incident
response for the backend gateway.
- [docs/observability.md](./docs/observability.md) — metrics, logs and tracing.
- [docs/group-epoch-sync.md](./docs/group-epoch-sync.md) — the group-control
sequence log and how a client catches up.
- [docs/signal-integration.md](./docs/signal-integration.md) — Signal library
evaluation and the client-side crypto split.
- [docs/security/](./docs/security/) — rate limits, TLS/pinning, audit logging.

## Backend

- [apps/backend/docs/](./apps/backend/docs/) — REST/socket API references,
contract schemas, E2EE onboarding, device and prekey APIs, delivery fan-out,
push/storage jobs, security hardening.
- E2EE specifics: `e2ee-onboarding.md`, `message-encryption-migration.md`
(ciphertext-only cutover), `signal-migration.md` (Phase-1 → Signal),
`mls-group-membership.md`, `mls-group-files.md`, `mls-key-packages.md`.

## Web

- [apps/web/docs/](./apps/web/docs/) — client concepts (E2EE stack, message
pipeline, file encryption, auth/device lifecycle, local search) and contract
references (response types, IndexedDB schemas, REST/WebSocket clients).

## AI agent and contracts

- [apps/ai_agent/docs/](./apps/ai_agent/docs/) — chat, index search, transfer
analysis, RAG architecture, Weaviate schema.
- [contracts/docs/](./contracts/docs/) — Soroban contract APIs.
- [contracts/README.md](./contracts/README.md) — contract workspace overview.
52 changes: 52 additions & 0 deletions docs/adr/0000-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR-NNNN: <short imperative title>

- **Status:** proposed | accepted | superseded by ADR-NNNN (drop it if nothing supersedes this)
- **Date:** YYYY-MM-DD (for backfilled records: the date the record was written, with the note that the decision predates it)
- **Supersedes:** ADR-NNNN (drop the line if nothing, and link the replacement
record when there is one)

## Context

What forced a decision. Two or three short paragraphs at most: the constraint, the
thing that broke or would have broken, and the options that were actually on the
table. Link the docs, schema or code the decision is about, with file paths.

Write down what the codebase looked like *before* the decision. An ADR whose
context only makes sense to someone who was in the review thread is not doing
its job.

## Decision

What was chosen, in the present tense, as a rule somebody can follow. If the
decision is enforceable in code (a check constraint, a validator, a rule in the
send path), say where.

## Consequences

What this buys, and what it costs. Both are required — an ADR with only upsides
is missing the part that will matter in six months.

- Positive consequence.
- Negative consequence, or the thing this makes harder later.
- Follow-on work this created, with the issue number if there is one.

## Alternatives rejected

One bullet per alternative, each with the reason it lost. If the rejection
reason is not recorded anywhere in the repo and you are reconstructing it,
say so explicitly instead of writing a plausible rationale — an unverified
"we rejected X because Y" is worse than "the record does not say why X was
rejected".

- **Alt A** — why it lost.
- **Alt B** — why it lost.

## Sources

Everything a reader needs to check this record against the code:

- schema: `apps/backend/src/db/schema.ts` (`<symbol>`)
- migration: `apps/backend/drizzle/NNNN_....sql`
- implementation: `path/to/file.ts`
- tests: `apps/backend/src/__tests__/<file>.test.ts`
- discussion: issue #NNN, PR #NNN (full links)
91 changes: 91 additions & 0 deletions docs/adr/0001-single-devices-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# ADR-0001: One canonical `devices` table

- **Status:** accepted
- **Date:** backfilled 2026-09-23 (decision predates this record)

## Context

Two device-shaped concerns arrived at different times: auth/prekey identity
(a device has an Ed25519 identity key, publishes prekeys, gets revoked) and
realtime/push plumbing (a device has a socket, a last-seen timestamp, a Web Push
subscription, and is an envelope recipient).

The first pass gave the identity side its own table,
`user_devices` (PR #206), with `GET /devices` listing from it (PR #207). The
second pass needed "which devices should this envelope be fanned out to, and
which of them are online" — answered from a different table. Nothing kept the
two in sync: a device could be revoked in one and still be an envelope
recipient in the other, and a sibling-device coverage check
(`services/messageFanout.ts`, `fetchSiblingDeviceIds`) could pass while the
realtime layer delivered to a device the auth layer considered gone.

## Decision

There is exactly one device registry, `devices`, and every other device-shaped
fact is a column on it or a foreign key into it.

- `devices` carries identity (`identity_public_key`, `registration_id`),
liveness (`last_seen_at`, `push_enabled`, `revoked_at`, `stale_flagged_at`),
and capability advertisement (`capabilities`).
- `messages.sender_device_id`, `message_envelopes.recipient_device_id`,
`push_subscriptions.device_id`, `device_prekeys.device_id`,
`device_key_history.device_id`, and the `mls_*` tables all FK into `devices`.
- `routes/userDevices.ts` is kept as a legacy alias route exposing only a
public-key lookup, not a second store.
- Uniqueness is per `(user_id, identity_public_key)` — the same identity key
re-registering is the same device, not a new row.

## Consequences

- Revocation is one write with one meaning: `revoked_at` on `devices` is
respected by the auth layer, the envelope fan-out path
(`isNull(devices.revokedAt)` in `lib/messageFanout.ts`,
`services/deliveryPipeline.ts`, `services/fanout.ts`,
`services/pushFilter.ts`) and push at the same time. There is no window
where the tables disagree.
- `revoked_at` is a timestamp rather than a boolean, so revocation keeps audit
history and records *when* it happened.
- The table is now on the hot path for auth, messaging, realtime and push. Every
new device-shaped requirement adds a column to it, and a partial index
(`devices_user_id_active_idx`) exists so the common "active devices for this
user" query does not have to scan revoked rows.
- Deleting a device row cascades to prekeys, envelopes and push subscriptions;
`DELETE /devices/:id` therefore revokes instead of deleting.

## Alternatives rejected

- **A separate `user_devices` table for identity, `devices` for realtime**
(shipped in PR #206, then merged away). Rejected because the two tables could
silently diverge: a revoked device would keep receiving envelopes. The
comment in `routes/devices.ts` records the merge explicitly.
- **A boolean `is_revoked` instead of `revoked_at`.** Rejected in favour of the
timestamp, which preserves audit history and supports the device-GC staleness
window. The frontend `Device.isRevoked` type is a leftover of the boolean
shape and is documented as drift (`apps/web/docs/contracts-response-types.md`,
DRIFT-2).
- **Deleting revoked device rows** rather than keeping them with `revoked_at`.
Rejected: it would cascade-delete audit-relevant history and orphan
envelope/prekey rows.

## Sources

- schema: `apps/backend/src/db/schema.ts:220-270` (`devices`, `devicePrekeys`)
- routes: `apps/backend/src/routes/devices.ts:1-7` (merge rationale),
`apps/backend/src/routes/userDevices.ts`
- migration: `apps/backend/drizzle/0000_lean_scrambler.sql` (`CREATE TABLE
"devices"`, `devices_user_identity_idx`, `devices_user_id_active_idx`)
- docs: `apps/backend/docs/api-devices.md:14-18`
- discussion: PR #206 (`user_devices` identity schema, later merged away),
PR #207 (`GET /devices`); issues #103/#107 are cited by the route comment

Verified by:

```sh
grep -rn "devices" apps/backend/drizzle/0000_lean_scrambler.sql
```

which shows `messages.sender_device_id`,
`message_envelopes.recipient_device_id`, `push_subscriptions.device_id`,
`device_prekeys.device_id` and the `mls_*` tables all referencing
`public.devices(id)`, and by the absence of any other `%device%` table in the
create statements.
101 changes: 101 additions & 0 deletions docs/adr/0002-message-ordering-by-createdat-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# ADR-0002: Order messages by `(createdAt, id)`, not a per-conversation sequence counter

- **Status:** accepted (supersedes [ADR-0007](./0007-per-conversation-sequence-numbers.md))
- **Date:** backfilled 2026-09-23 (decision predates this record)
- **Supersedes:** ADR-0007 (per-conversation monotonic `sequence_number`)

## Context

Chat messages need a total order for rendering, and offline catch-up needs a
resumable cursor over what a device missed.

Issue #178 asked for a per-conversation monotonic `sequence_number`, assigned
atomically inside the write transaction (`UPDATE conversations ... RETURNING` on
a `last_sequence_number` column) so concurrent senders could not duplicate or
skip values. PR #324 implemented exactly that: a `lastSequenceNumber` on
`conversations`, `messages.sequenceNumber` as `bigint`, assignment wired into
every insert path — REST, Socket.IO, AI assistant replies, system events — and
history pagination switched to `sequenceNumber` cursors.

It was then dropped again. The reason is in the schema comment and the sync
route: a counter that is monotonic *within one conversation* cannot also be a
coherent cursor *across conversations*. Offline catch-up (`GET /sync`) returns
the envelopes a single device missed, and those span every conversation that
device participates in. A per-conversation counter gives those rows no shared
order and no comparable cursor; the client would have to keep one cursor per
conversation and poll each of them.

The alternative — two counters, one per-conversation for display and one global
for sync — was judged not worth the complexity of keeping both correct under
concurrent writes.

## Decision

Chat messages are ordered by `(created_at, id)` and nothing else.

- `messages` has `index('messages_conversation_created_idx').on(conversationId, createdAt)`.
- Cursors are the pair itself: `GET /conversations/:id/messages?before=<id>`
resolves the message's `(createdAt, id)`
(`apps/backend/src/routes/conversations.ts:596-629`) and pages with
`createdAt < c.createdAt OR (createdAt = c.createdAt AND id < c.id)`.
- `GET /sync` uses the *envelope's* `(createdAt, id)` encoded as the opaque
cursor `"<epochMillis>:<uuid>"` (`apps/backend/src/routes/sync.ts:21-22,
119-142`).
- `id` is only a tiebreaker for same-millisecond inserts. It is a random UUID
and carries no chronological meaning.
- Where ordering must not be skippable — group control events — a separate,
strictly monotonic log is used instead of the message table; see
`docs/group-epoch-sync.md`.

## Consequences

- One ordering for the timeline and for sync. A cursor is comparable across
conversations, so offline catch-up is a single request per device.
- No counter to maintain, no row lock on `conversations` for every message
insert, and no second counter to keep consistent.
- Clock skew and same-millisecond writes are handled by the timestamp tiebreak
rather than being impossible; that is cheaper, but the order is only as good
as the server clock. Both timestamps are server-generated (`defaultNow()`),
which keeps a client from choosing its own position in the timeline.
- Pages can interleave when two messages share a millisecond; the `id` tiebreak
keeps pagination from skipping or duplicating rows, it does not make the
order meaningful.
- Live correctness gap, still open: seven frontend types still declare
`sequenceNumber`, and the client sorts on `msg.sequenceNumber ?? 0`, so live
ordering collapses to `0` for every synced message
(`apps/web/docs/contracts-response-types.md`, DRIFT-1). This ADR is about the
server model; the stale client types are a follow-up.

## Alternatives rejected

- **Per-conversation monotonic `sequence_number`** (#178, implemented in
PR #324, then reverted). Rejected because a per-conversation counter is not a
usable cross-conversation sync cursor. This is stated directly in the code:
`apps/backend/src/db/schema.ts:117-121` and
`apps/backend/src/routes/sync.ts:21-22`.
- **Two counters** — per-conversation for display, global for sync. Rejected as
not worth the complexity (schema comment, `schema.ts:117-121`).
- **Client-side ordering by local receive time.** Not recorded anywhere in the
repo; the server timestamps are used instead, but no review discussion
rejecting this was found, so treat the rejection as unverified.
- **Ordering by `id` alone.** Rejected implicitly: `id` is a random UUID
(`defaultRandom()`), so it cannot order anything; it is a tiebreak only.

## Sources

- schema: `apps/backend/src/db/schema.ts:117-121` (ordering comment),
`messages` table and `messages_conversation_created_idx`
- pagination: `apps/backend/src/routes/conversations.ts:596-629`
- sync cursor: `apps/backend/src/routes/sync.ts:21-22,119-142`
- the monotonic log that *does* exist, for group control:
`apps/backend/drizzle` `group_control_events`, `docs/group-epoch-sync.md:12-25`
- docs: `apps/backend/docs/api-conversations.md:123-124`,
`apps/backend/docs/api-messages-sync.md:236-241`,
`apps/web/docs/contracts-response-types.md` (DRIFT-1, DRIFT-4)
- discussion: issue #178, PR #324 (both about the counter that was dropped)

Citation caveat: the comment at `schema.ts:117-121` cites "(#137)" for the
offline-sync cursor argument, and `sync.ts:21` cites "(#128)". Neither number
resolves to that topic in this repo — #137 is a `cargo clippy` job and #128 is
the `treasury_proposals` table — so the numbers were reused or renumbered at
some point. The *reasoning* is in the code; the issue numbers are not reliable.
Loading