Skip to content

Commit 7b397e5

Browse files
authored
fix(engine): reserve deterministic 1:1 DM ids atomically (#303)
Binds every deterministic 1:1 DM conversation id to exactly one (workspace, sorted participant pair), reserved atomically before any conversation state is created. A collision now returns 409 dm_conversation_id_collision instead of silently resolving into another pair's conversation. Migration 0033 adds dm_conversation_reservations and backfills conversations with exactly two distinct participants. One-row rosters are deliberately skipped: dm_participants cascades on agent deletion, so an ordinary two-party DM collapses to a single row while its id still encodes the original pair, and reserving those as a self-pair collided on real production data. scripts/audit-dm-reservations.mjs is the read-only pre-flight; run it before applying the migration.
1 parent 21a5390 commit 7b397e5

11 files changed

Lines changed: 997 additions & 64 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Trajectory: Implement atomic ResolveOrReserve for deterministic 1:1 DMs
2+
3+
> **Status:** ✅ Completed
4+
> **Confidence:** 95%
5+
> **Started:** August 2, 2026 at 08:11 AM
6+
> **Completed:** August 2, 2026 at 08:26 AM
7+
8+
---
9+
10+
## Summary
11+
12+
Added atomic 1:1 DM ResolveOrReserve reservations with migration backfill, fail-closed digest collision handling, and concurrent negative-control coverage.
13+
14+
**Approach:** Standard approach
15+
16+
---
17+
18+
## Key Decisions
19+
20+
### Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows
21+
- **Chose:** Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows
22+
- **Reasoning:** The reservation must name workspace plus sorted pair in one conflict-detecting upsert. Reserving first prevents colliding requests from creating shared metadata before a winner exists, and a separate table can backfill existing DMs without changing nullable group-DM columns.
23+
24+
---
25+
26+
## Chapters
27+
28+
### 1. Work
29+
*Agent: default*
30+
31+
- Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows: Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows
32+
- The reservation-first design passes the real concurrent send path and its negative control. Full engine build, lint, and 536-test suite are green; root/package-wide unrelated runner/load failures were isolated and the SDK timeout rerun passed 416/416.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"id": "traj_pzkvekhsexoq",
3+
"version": 1,
4+
"task": {
5+
"title": "Implement atomic ResolveOrReserve for deterministic 1:1 DMs"
6+
},
7+
"status": "completed",
8+
"startedAt": "2026-08-02T06:11:12.788Z",
9+
"completedAt": "2026-08-02T06:26:48.981Z",
10+
"agents": [
11+
{
12+
"name": "default",
13+
"role": "lead",
14+
"joinedAt": "2026-08-02T06:13:21.452Z"
15+
}
16+
],
17+
"chapters": [
18+
{
19+
"id": "chap_hdc34f62008i",
20+
"title": "Work",
21+
"agentName": "default",
22+
"startedAt": "2026-08-02T06:13:21.452Z",
23+
"endedAt": "2026-08-02T06:26:48.981Z",
24+
"events": [
25+
{
26+
"ts": 1785651201456,
27+
"type": "decision",
28+
"content": "Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows: Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows",
29+
"raw": {
30+
"question": "Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows",
31+
"chosen": "Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows",
32+
"alternatives": [],
33+
"reasoning": "The reservation must name workspace plus sorted pair in one conflict-detecting upsert. Reserving first prevents colliding requests from creating shared metadata before a winner exists, and a separate table can backfill existing DMs without changing nullable group-DM columns."
34+
},
35+
"significance": "high"
36+
},
37+
{
38+
"ts": 1785652008647,
39+
"type": "reflection",
40+
"content": "The reservation-first design passes the real concurrent send path and its negative control. Full engine build, lint, and 536-test suite are green; root/package-wide unrelated runner/load failures were isolated and the SDK timeout rerun passed 416/416.",
41+
"raw": {
42+
"focalPoints": [
43+
"atomicity",
44+
"negative-control",
45+
"validation"
46+
],
47+
"confidence": 0.95
48+
},
49+
"significance": "high",
50+
"tags": [
51+
"focal:atomicity",
52+
"focal:negative-control",
53+
"focal:validation",
54+
"confidence:0.95"
55+
]
56+
}
57+
]
58+
}
59+
],
60+
"retrospective": {
61+
"summary": "Added atomic 1:1 DM ResolveOrReserve reservations with migration backfill, fail-closed digest collision handling, and concurrent negative-control coverage.",
62+
"approach": "Standard approach",
63+
"confidence": 0.95
64+
},
65+
"commits": [],
66+
"filesChanged": [],
67+
"projectId": "AgentWorkforce/relaycast",
68+
"tags": [],
69+
"_trace": {
70+
"startRef": "21a5390929e16c21058e08256dcdc33434e8e108",
71+
"endRef": "21a5390929e16c21058e08256dcdc33434e8e108"
72+
}
73+
}

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1616

1717
Packages without a separate changelog are covered by the cross-package notes below.
1818

19-
## [Unreleased]
19+
## [Unreleased - Patch]
20+
21+
### Fixed
22+
23+
- 1:1 DM resolution now atomically reserves each deterministic conversation ID for its workspace and participant pair. A collision returns `409 dm_conversation_id_collision` instead of silently aliasing another conversation.
2024

2125
## [6.3.1] - 2026-07-31
2226

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,13 @@ GET /workspace/events
411411
Activity feed channel-message items include `channel_id` and `channel_name`; DM items include
412412
`conversation_id`.
413413

414+
`POST /dm` can return **`409 dm_conversation_id_collision`**. A 1:1 conversation id is derived
415+
deterministically from `(workspace, sorted agent pair)`, and that binding is reserved
416+
atomically before any conversation state is created, so a derivation that would name another
417+
pair's conversation fails closed instead of resolving to it. Two cases produce the error: the
418+
identifier is already bound to a different pair, or the pair is already bound to a different
419+
identifier. It is not retryable — the same inputs collide again.
420+
414421
`GET /workspace/events?since=<seq>&limit=<n>` is the durable, cursor-based log behind the
415422
workspace stream (30-day retention by default): every published stream frame is appended with a
416423
per-workspace monotonic `seq` (also stamped on the live frame), so observers can reconcile after

openapi.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2687,6 +2687,20 @@ paths:
26872687
type: boolean
26882688
data:
26892689
$ref: '#/components/schemas/DmSendResponse'
2690+
'409':
2691+
description: |
2692+
`dm_conversation_id_collision` — the deterministic 1:1 conversation
2693+
identifier could not be reserved for this exact
2694+
`(workspace, sorted agent pair)` tuple. A 1:1 DM id is derived from
2695+
that tuple, and the reservation is atomic, so the request fails
2696+
closed rather than resolving to another pair's conversation. Two
2697+
cases produce it: the identifier is already bound to a different
2698+
pair, or the pair is already bound to a different identifier. It is
2699+
not retryable — the same inputs will collide again.
2700+
content:
2701+
application/json:
2702+
schema:
2703+
$ref: '#/components/schemas/ErrorResponse'
26902704

26912705
/dm/conversations:
26922706
get:

packages/engine/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10-
## [Unreleased]
10+
## [Unreleased - Patch]
11+
12+
### Fixed
13+
14+
- `sendDm` atomically reserves each deterministic 1:1 conversation ID for its workspace and sorted participant pair, so a collision returns `409 dm_conversation_id_collision` on every driver instead of resolving to another pair's conversation or failing with an uncaught database error. Migration `0033` adds `dm_conversation_reservations` and backfills existing 1:1 DMs; see the migration header for the pre-flight audit to run before applying it.
1115

1216
## [6.3.1] - 2026-07-31
1317

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
-- Bind every deterministic 1:1 DM id to the workspace and sorted participant
2+
-- pair that derived it. The primary key is the atomic reservation seam: a
3+
-- conflicting digest can never overwrite or alias another pair's conversation.
4+
CREATE TABLE dm_conversation_reservations (
5+
conversation_id TEXT PRIMARY KEY,
6+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
7+
participant_one_id TEXT NOT NULL,
8+
participant_two_id TEXT NOT NULL,
9+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
10+
CONSTRAINT dm_conversation_reservations_sorted_pair_check
11+
CHECK (participant_one_id <= participant_two_id)
12+
);
13+
14+
CREATE UNIQUE INDEX dm_conversation_reservations_pair_unique
15+
ON dm_conversation_reservations (workspace_id, participant_one_id, participant_two_id);
16+
17+
-- PRE-FLIGHT AUDIT (run BOTH read-only queries BEFORE applying this migration).
18+
-- The backfill can abort for two independent reasons, and each needs its own
19+
-- check. An earlier version of this comment shipped only query (a) and claimed an
20+
-- empty result meant the migration would apply cleanly. That was wrong: a
21+
-- duplicate pair passes (a) and still aborts the backfill on the pair-uniqueness
22+
-- index. Raised in review of PR #303.
23+
--
24+
-- (a) Roster shape. The backfill aborts if any single legacy 1:1 has a roster
25+
-- that is not 1 or 2 distinct participants. That is deliberate - inventing a
26+
-- tuple would bind a conversation to the wrong pair - but one corrupt row
27+
-- blocks the whole deployment.
28+
--
29+
-- SELECT dc.id, dc.workspace_id, COUNT(DISTINCT dp.agent_id) AS participants
30+
-- FROM dm_conversations dc
31+
-- LEFT JOIN dm_participants dp ON dp.conversation_id = dc.id
32+
-- WHERE dc.dm_type = '1:1'
33+
-- GROUP BY dc.id, dc.workspace_id
34+
-- HAVING participants NOT BETWEEN 1 AND 2;
35+
--
36+
-- (b) Duplicate pairs. Two DISTINCT legacy 1:1 conversations in one workspace
37+
-- that resolve to the same sorted pair both satisfy (a), but only one can be
38+
-- reserved - the second violates dm_conversation_reservations_pair_unique and
39+
-- aborts the migration. This is exactly what a pre-deterministic id scheme
40+
-- leaves behind, so it is the likelier of the two in practice.
41+
--
42+
-- SELECT workspace_id, participant_one_id, participant_two_id,
43+
-- COUNT(*) AS conversations
44+
-- FROM (
45+
-- SELECT dc.workspace_id,
46+
-- MIN(dp.agent_id) AS participant_one_id,
47+
-- MAX(dp.agent_id) AS participant_two_id
48+
-- FROM dm_conversations dc
49+
-- JOIN dm_participants dp ON dp.conversation_id = dc.id
50+
-- WHERE dc.dm_type = '1:1'
51+
-- GROUP BY dc.id, dc.workspace_id
52+
-- HAVING COUNT(DISTINCT dp.agent_id) BETWEEN 1 AND 2
53+
-- )
54+
-- GROUP BY workspace_id, participant_one_id, participant_two_id
55+
-- HAVING conversations > 1;
56+
--
57+
-- (c) Ids that do not match the CURRENT derivation. This one cannot be written
58+
-- in SQL - it needs SHA-256, which SQLite does not have - and it is the check
59+
-- that matters most operationally, because it is the only failure that is
60+
-- INVISIBLE AT MIGRATION TIME. (a) and (b) abort the migration loudly. (c)
61+
-- lets it succeed, and then every subsequent DM between that pair returns 409
62+
-- forever, because the backfill reserved the pair under an id the send path
63+
-- will never re-derive.
64+
--
65+
-- Run: node scripts/audit-dm-reservations.mjs --sqlite <path>
66+
-- D1: wrangler d1 execute <DB> --json --command "<see script header>" \
67+
-- | node scripts/audit-dm-reservations.mjs --stdin
68+
--
69+
-- That script also re-runs (a) and (b), so it is the single command to trust.
70+
--
71+
-- All three clean means this migration will apply AND no existing pair will start
72+
-- failing afterwards. Remediate anything any of them returns - for (b) that means
73+
-- deciding which conversation survives, since the reservation can only bind one;
74+
-- for (c) it means re-keying the conversation to the derived id, or seeding its
75+
-- reservation under the derived id, before deploying the code that reserves.
76+
77+
-- Backfill ONLY conversations with exactly two distinct participants.
78+
--
79+
-- A one-row roster is ambiguous and MUST NOT be reserved. It looks like a
80+
-- self-DM, but `dm_participants.agent_id` cascades on agent deletion, so a
81+
-- perfectly ordinary two-party 1:1 collapses to a single row the moment one
82+
-- participant's agent is deleted - while its id still encodes the ORIGINAL pair.
83+
--
84+
-- Reading those as (X, X) is wrong twice over. Several orphans belonging to the
85+
-- same surviving agent all collapse to the same (workspace, X, X) tuple and
86+
-- collide on the pair-uniqueness index, aborting the migration; and any that
87+
-- survived would reserve a self-DM tuple against an id no derivation produces,
88+
-- so that agent's next self-DM would 409 forever.
89+
--
90+
-- This is not hypothetical. An earlier version of this backfill used
91+
-- MIN/MAX over 1-2 participants; audited against production it produced 4
92+
-- colliding pair groups and 30 mismatched ids, all of them orphaned two-party
93+
-- conversations, and would have failed the deployment. Restricted to exactly two
94+
-- participants the same data yields zero findings across 3425 conversations.
95+
--
96+
-- Skipping is safe rather than merely convenient. An unreserved conversation is
97+
-- in exactly the state every conversation was in before this migration: the
98+
-- first send through the reservation path claims it, and because a genuine
99+
-- self-DM's id already equals its derivation, that claim adopts the existing
100+
-- conversation instead of creating a second one. Orphaned two-party rows are
101+
-- simply never re-derived, so they stay readable and inert.
102+
--
103+
-- Malformed rosters (zero, or more than two) are skipped for the same reason.
104+
-- The earlier version aborted the whole migration on them; skipping avoids
105+
-- inventing a tuple just as effectively without blocking a deployment, and any
106+
-- future send still goes through the reservation path.
107+
INSERT INTO dm_conversation_reservations (
108+
conversation_id,
109+
workspace_id,
110+
participant_one_id,
111+
participant_two_id,
112+
created_at
113+
)
114+
SELECT
115+
dc.id,
116+
dc.workspace_id,
117+
MIN(dp.agent_id),
118+
MAX(dp.agent_id),
119+
dc.created_at
120+
FROM dm_conversations dc
121+
JOIN dm_participants dp ON dp.conversation_id = dc.id
122+
WHERE dc.dm_type = '1:1'
123+
GROUP BY dc.id, dc.workspace_id, dc.created_at
124+
HAVING COUNT(DISTINCT dp.agent_id) = 2;

packages/engine/src/db/schema.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
uniqueIndex,
88
primaryKey,
99
foreignKey,
10+
check,
1011
} from 'drizzle-orm/sqlite-core';
1112
import { sql } from 'drizzle-orm';
1213
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core';
@@ -602,6 +603,33 @@ export const dmConversations = sqliteTable(
602603
],
603604
);
604605

606+
// ============================================
607+
// 1:1 DM Conversation Reservations
608+
// ============================================
609+
export const dmConversationReservations = sqliteTable(
610+
'dm_conversation_reservations',
611+
{
612+
conversationId: text('conversation_id').primaryKey(),
613+
workspaceId: text('workspace_id')
614+
.notNull()
615+
.references(() => workspaces.id, { onDelete: 'cascade' }),
616+
participantOneId: text('participant_one_id').notNull(),
617+
participantTwoId: text('participant_two_id').notNull(),
618+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
619+
},
620+
(table) => [
621+
check(
622+
'dm_conversation_reservations_sorted_pair_check',
623+
sql`${table.participantOneId} <= ${table.participantTwoId}`,
624+
),
625+
uniqueIndex('dm_conversation_reservations_pair_unique').on(
626+
table.workspaceId,
627+
table.participantOneId,
628+
table.participantTwoId,
629+
),
630+
],
631+
);
632+
605633
// ============================================
606634
// DM Participants
607635
// ============================================

0 commit comments

Comments
 (0)