Skip to content

docs(adr): add the ADR index, template and the six backfilled decisions - #608

Closed
jarik2014 wants to merge 1 commit into
codebestia:devfrom
jarik2014:docs/550-adr-index
Closed

jarik2014 wants to merge 1 commit into
codebestia:devfrom
jarik2014:docs/550-adr-index

Conversation

@jarik2014

Copy link
Copy Markdown

Closes #550

What this adds

  • docs/adr/README.md — the index. Lists each record with its status
    (accepted / superseded), plus how to add one.
  • docs/adr/0000-template.md — context / decision / consequences / status, with
    a required "alternatives rejected" section (and an instruction to say so
    explicitly when the repo does not record why something was rejected).
  • Six backfilled records for the decisions the issue names, plus two records for
    decisions that were shipped and later superseded — the replacement only makes
    sense against what it replaced, and remnants of both are still in the tree.
ADR Decision Status
0001 one canonical devices table accepted (supersedes 0008)
0002 order messages by (createdAt, id), not a sequence counter accepted (supersedes 0007)
0003 ciphertext-only message storage; plaintext archived, not tombstoned accepted
0004 per-device envelopes, not one shared ciphertext accepted
0005 Phase-1 sealed box → Phase-2 Signal, per device pair, history never rewritten accepted (migration not yet activated)
0006 MLS for groups: one group ciphertext, epochs as the unit of membership accepted
0007 per-conversation monotonic sequence_number superseded by 0002
0008 a separate user_devices table superseded by 0001

Docs only — 11 markdown files, no code, schema, config or lockfile touched.

Where the content came from

Everything is sourced from the repository: schema comments, route header
comments, apps/backend/docs/ and apps/web/docs/, the 0000 migration,
docs/, and the review history via gh.

Verification

Each decision, with the command used to substantiate it and the output that
matters.

0001 — one canonical devices table. Every other device-shaped table FKs
into it; the route header records the user_devices merge, PR #206 added the
table that was merged away.

$ grep -c 'REFERENCES "public"."devices"' apps/backend/drizzle/0000_lean_scrambler.sql
9
$ grep -n "single canonical device registry" apps/backend/src/db/schema.ts apps/backend/src/routes/devices.ts apps/backend/docs/api-devices.md
apps/backend/src/db/schema.ts:220:// signature validation. This is the single canonical device registry — it also
apps/backend/src/routes/devices.ts:4: * `devices` is the single canonical device registry (see #103/#107 — a
apps/backend/docs/api-devices.md:14:`devices` is the single canonical device registry. A previously-separate

The 9 FKs are device_key_history, device_prekeys, message_envelopes,
messages, mls_commits, mls_group_members, mls_key_packages,
mls_welcomes, push_subscriptions. grep -n 'CREATE TABLE' ... | grep -i dev
returns exactly three tables, all of them per-device data stores, and no second
device registry.

0002 — (createdAt, id) ordering, sequence counter dropped.

$ grep -n "Ordering is by (createdAt, id)" apps/backend/src/db/schema.ts
117:// Ordering is by (createdAt, id) — a monotonic per-conversation sequence
$ sed -n '117,120p' apps/backend/src/db/schema.ts
// Ordering is by (createdAt, id) — a monotonic per-conversation sequence
// counter was considered (and briefly implemented) but dropped: the same
// counter can't also serve as a coherent cross-conversation cursor for
// offline sync (#137), and maintaining two separate counters for two
$ grep -n "Cursor is the envelope's own" apps/backend/src/routes/sync.ts
21:// Cursor is the envelope's own (createdAt, id) — not messages.sequenceNumber.
$ grep -c "sequenceNumber" apps/backend/src/db/schema.ts
0

PR #324 implemented the counter (issue #178 asked for it) and it was reverted;
the schema comment and routes/sync.ts:21 are where the reversal is recorded.
docs/group-epoch-sync.md:12-25 explains why group control does keep a
sequence log while chat messages do not.

0003 — ciphertext-only storage, archive-then-purge.

$ grep -n "ciphertext: text\|systemPayload" apps/backend/src/db/schema.ts | head -3
137:    ciphertext: text('ciphertext'),
143:    systemPayload: jsonb('system_payload').$type<{ userId: string; change: string } | null>(),
167:      sql`${table.contentType} <> 'system' OR (${table.ciphertext} IS NULL AND ${table.systemPayload} IS NOT NULL)`,
$ grep -n "410" apps/backend/src/routes/conversations.ts | head -2
662:  res.status(410).json({

messages has no plaintext content column. The policy (and the rejected
in-place tombstone) is in apps/backend/docs/message-encryption-migration.md;
the PR #482 summary records "archive-then-purge, chosen over an in-place
tombstone".

0004 — per-device envelopes.

$ grep -n "fetchSiblingDeviceIds" apps/backend/src/lib/messageFanout.ts
42:export async function fetchSiblingDeviceIds(
$ grep -n "me_recipient_device_created_idx" apps/backend/drizzle/0000_lean_scrambler.sql
311:CREATE INDEX "me_recipient_device_created_idx" ON "message_envelopes" USING btree ("recipient_device_id","created_at");
$ sed -n '15,19p' apps/backend/src/lib/validateMessagePayload.ts
 * envelope requirement does not apply — a non-empty `ciphertext` is required
 * instead. Sending envelopes alongside `mlsEpoch` is rejected: the two
 * key-distribution models must not be mixed on one message, or recipients have
 * no unambiguous rule for which ciphertext to decrypt.

0005 — sealed box → Signal, per device pair.

$ grep -n '"protocol" "e2ee_protocol"' apps/backend/drizzle/0000_lean_scrambler.sql
117:	"protocol" "e2ee_protocol" DEFAULT 'sealed_box' NOT NULL,
$ grep -n "KNOWN_PROTOCOLS" apps/backend/src/lib/capabilities.ts | head -1
32:export const KNOWN_PROTOCOLS = ['sealed_box', 'signal', 'mls'] as const;
$ grep -n "throw new Error" apps/web/src/lib/signalClient.ts | head -1
98:    throw new Error(

The per-envelope protocol column exists and the enum mirrors
KNOWN_PROTOCOLS; the Phase-2 client adapter still throws, which is why 0005 is
recorded as "accepted, migration not yet activated" rather than done. The
rejected conversation-wide readiness gate and the rejected re-encrypt-history
option are both stated in apps/backend/docs/signal-migration.md; the library
choice (@signalapp/libsignal-client over the archived JS implementations) is in
docs/signal-integration.md.

0006 — MLS for groups.

$ grep -n 'CREATE TABLE "mls_' apps/backend/drizzle/0000_lean_scrambler.sql
139:CREATE TABLE "mls_commits" (
148:CREATE TABLE "mls_group_members" (
158:CREATE TABLE "mls_groups" (
168:CREATE TABLE "mls_key_packages" (
180:CREATE TABLE "mls_welcomes" (
$ grep -n "joined_at_epoch" apps/backend/drizzle/0000_lean_scrambler.sql | head -1
153:	"joined_at_epoch" bigint NOT NULL,

The rejected per-device fan-out for group content is spelled out in
apps/backend/docs/mls-group-files.md ("In a 30-person group with 3 devices each
that is 90 copies of the same bytes"), and the ban on mixing envelopes with
mlsEpoch in both validateMessagePayload.ts and the membership doc.

Link integrity

Throwaway script (/tmp/adr_linkcheck.py, not committed): walks every .md in
docs/adr/, resolves every relative link against the filesystem, skips
http(s) targets, exits non-zero on a miss.

$ python3 /tmp/adr_linkcheck.py
OK   docs/adr/0002-message-ordering-by-createdat-id.md -> ./0007-per-conversation-sequence-numbers.md
OK   docs/adr/0007-per-conversation-sequence-numbers.md -> ./0002-message-ordering-by-createdat-id.md
OK   docs/adr/0008-separate-user-devices-table.md -> ./0001-single-devices-table.md
OK   docs/adr/README.md -> ./0000-template.md
OK   docs/adr/README.md -> ./0001-single-devices-table.md
OK   docs/adr/README.md -> ./0002-message-ordering-by-createdat-id.md
OK   docs/adr/README.md -> ./0003-ciphertext-only-message-storage.md
OK   docs/adr/README.md -> ./0004-per-device-envelopes.md
OK   docs/adr/README.md -> ./0005-sealed-box-to-signal-migration.md
OK   docs/adr/README.md -> ./0006-mls-for-groups.md
OK   docs/adr/README.md -> ./0007-per-conversation-sequence-numbers.md
OK   docs/adr/README.md -> ./0008-separate-user-devices-table.md
OK   docs/adr/README.md -> ../../IMPLEMENTATION_DOCS.md

12 relative links checked, 0 broken

The same check over IMPLEMENTATION_DOCS.md reports 12 links, 0 broken.

Cross-link from IMPLEMENTATION_DOCS.md

$ grep -n "docs/adr/README.md" IMPLEMENTATION_DOCS.md
14:- [docs/adr/README.md](./docs/adr/README.md) — Architecture Decision Records:

docs/adr/README.md carries the reciprocal line ("Cross-referenced from
IMPLEMENTATION_DOCS.md"), matching docs/runbook.md, docs/observability.md
and docs/threat-model.md.

One thing to flag: IMPLEMENTATION_DOCS.md is listed in .gitignore and does
not exist on dev
(nor on GitHub — the three docs that reference it link to a
file only the maintainer has locally). The acceptance criterion asks for a
cross-link from it, so it is added here as a repo-root documentation index and
force-added (git add -f IMPLEMENTATION_DOCS.md) — it will not be re-ignored
once tracked. If it should stay local instead, drop that file from the PR and
paste the "Decisions" block into your own copy; everything else is unaffected.

Fidelity notes (things the repo does not substantiate)

The brief was to write what the code actually shows and mark unverified
rationale as unverified, rather than producing plausible-sounding reasons. Three
kinds of gap came up, and each is called out in the record itself:

  1. Rejections with no recorded rationale are marked inside the ADR: a static
    shared conversation key (0004), sender-keys for groups and searchable
    encryption over ciphertext (0003, 0006). The chosen model is in the code;
    the debate is not.
  2. Issue numbers in code comments do not resolve to their topic. The
    ordering comment cites #137 (actually a cargo clippy job) and
    routes/sync.ts:21 cites #128 (the treasury proposals table) for the
    offline-sync argument; crypto.ts credits sibling-device coverage to #138
    (a Rust toolchain pin) and the device_set_mismatch retry to #133 (a
    withdrawal action) — the sibling-device issue is Multi-device self-sync (sender's own other devices) #188. The behaviour is
    verified in code; the comment numbers are not, and the ADRs say so.
  3. Migration files named in the docs are not in the tree. The docs reference
    0001_mls_group_state.sql, 0001_group_control_events.sql,
    0003_ciphertext_only_messages.sql, 0004_envelope_protocol.sql and a
    rollback script; apps/backend/drizzle/ contains only
    0000_lean_scrambler.sql (plus meta/), where all the resulting columns and
    tables do exist. End state is verifiable, the per-migration history is not —
    the records say exactly that instead of pretending otherwise.

Also worth a follow-up ticket (found while writing 0002 and 0004): the frontend
still types and sorts on sequenceNumber for ordering, which is DRIFT-1 in
apps/web/docs/contracts-response-types.md — the ordering now silently collapses
to 0 for live and synced messages. The 410 on server-side search also points
readers at github.com/DripWave/clicked/blob/main/docs/message-encryption-migration.md,
the wrong owner and path; the doc lives at
apps/backend/docs/message-encryption-migration.md. Both are documented in the
ADRs, neither is fixed here — docs only.

Testing

No code changed, so there is nothing to run beyond the greps and the link check
above.

@codebestia codebestia closed this Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants