Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Trajectory: Implement atomic ResolveOrReserve for deterministic 1:1 DMs

> **Status:** ✅ Completed
> **Confidence:** 95%
> **Started:** August 2, 2026 at 08:11 AM
> **Completed:** August 2, 2026 at 08:26 AM

---

## Summary

Added atomic 1:1 DM ResolveOrReserve reservations with migration backfill, fail-closed digest collision handling, and concurrent negative-control coverage.

**Approach:** Standard approach

---

## Key Decisions

### Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows
- **Chose:** Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows
- **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.

---

## Chapters

### 1. Work
*Agent: default*

- 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
- 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"id": "traj_pzkvekhsexoq",
"version": 1,
"task": {
"title": "Implement atomic ResolveOrReserve for deterministic 1:1 DMs"
},
"status": "completed",
"startedAt": "2026-08-02T06:11:12.788Z",
"completedAt": "2026-08-02T06:26:48.981Z",
"agents": [
{
"name": "default",
"role": "lead",
"joinedAt": "2026-08-02T06:13:21.452Z"
}
],
"chapters": [
{
"id": "chap_hdc34f62008i",
"title": "Work",
"agentName": "default",
"startedAt": "2026-08-02T06:13:21.452Z",
"endedAt": "2026-08-02T06:26:48.981Z",
"events": [
{
"ts": 1785651201456,
"type": "decision",
"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",
"raw": {
"question": "Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows",
"chosen": "Reserve deterministic 1:1 DM ids in a dedicated tuple table before creating channel or conversation rows",
"alternatives": [],
"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."
},
"significance": "high"
},
{
"ts": 1785652008647,
"type": "reflection",
"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.",
"raw": {
"focalPoints": [
"atomicity",
"negative-control",
"validation"
],
"confidence": 0.95
},
"significance": "high",
"tags": [
"focal:atomicity",
"focal:negative-control",
"focal:validation",
"confidence:0.95"
]
}
]
}
],
"retrospective": {
"summary": "Added atomic 1:1 DM ResolveOrReserve reservations with migration backfill, fail-closed digest collision handling, and concurrent negative-control coverage.",
"approach": "Standard approach",
"confidence": 0.95
},
"commits": [],
"filesChanged": [],
"projectId": "AgentWorkforce/relaycast",
"tags": [],
"_trace": {
"startRef": "21a5390929e16c21058e08256dcdc33434e8e108",
"endRef": "21a5390929e16c21058e08256dcdc33434e8e108"
}
}
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

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

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- 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.

## [6.3.1] - 2026-07-31

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,13 @@ GET /workspace/events
Activity feed channel-message items include `channel_id` and `channel_name`; DM items include
`conversation_id`.

`POST /dm` can return **`409 dm_conversation_id_collision`**. A 1:1 conversation id is derived
deterministically from `(workspace, sorted agent pair)`, and that binding is reserved
atomically before any conversation state is created, so a derivation that would name another
pair's conversation fails closed instead of resolving to it. Two cases produce the error: the
identifier is already bound to a different pair, or the pair is already bound to a different
identifier. It is not retryable — the same inputs collide again.

`GET /workspace/events?since=<seq>&limit=<n>` is the durable, cursor-based log behind the
workspace stream (30-day retention by default): every published stream frame is appended with a
per-workspace monotonic `seq` (also stamped on the live frame), so observers can reconcile after
Expand Down
14 changes: 14 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2687,6 +2687,20 @@ paths:
type: boolean
data:
$ref: '#/components/schemas/DmSendResponse'
'409':
description: |
`dm_conversation_id_collision` — the deterministic 1:1 conversation
identifier could not be reserved for this exact
`(workspace, sorted agent pair)` tuple. A 1:1 DM id is derived from
that tuple, and the reservation is atomic, so the request fails
closed rather than resolving to another pair's conversation. Two
cases produce it: the identifier is already bound to a different
pair, or the pair is already bound to a different identifier. It is
not retryable — the same inputs will collide again.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

/dm/conversations:
get:
Expand Down
6 changes: 5 additions & 1 deletion packages/engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- `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.

## [6.3.1] - 2026-07-31

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
-- Bind every deterministic 1:1 DM id to the workspace and sorted participant
-- pair that derived it. The primary key is the atomic reservation seam: a
-- conflicting digest can never overwrite or alias another pair's conversation.
CREATE TABLE dm_conversation_reservations (
conversation_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
participant_one_id TEXT NOT NULL,
participant_two_id TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
CONSTRAINT dm_conversation_reservations_sorted_pair_check
CHECK (participant_one_id <= participant_two_id)
);

CREATE UNIQUE INDEX dm_conversation_reservations_pair_unique
ON dm_conversation_reservations (workspace_id, participant_one_id, participant_two_id);

-- PRE-FLIGHT AUDIT (run BOTH read-only queries BEFORE applying this migration).
-- The backfill can abort for two independent reasons, and each needs its own
-- check. An earlier version of this comment shipped only query (a) and claimed an
-- empty result meant the migration would apply cleanly. That was wrong: a
-- duplicate pair passes (a) and still aborts the backfill on the pair-uniqueness
-- index. Raised in review of PR #303.
--
-- (a) Roster shape. The backfill aborts if any single legacy 1:1 has a roster
-- that is not 1 or 2 distinct participants. That is deliberate - inventing a
-- tuple would bind a conversation to the wrong pair - but one corrupt row
-- blocks the whole deployment.
--
-- SELECT dc.id, dc.workspace_id, COUNT(DISTINCT dp.agent_id) AS participants
-- FROM dm_conversations dc
-- LEFT JOIN dm_participants dp ON dp.conversation_id = dc.id
-- WHERE dc.dm_type = '1:1'
-- GROUP BY dc.id, dc.workspace_id
-- HAVING participants NOT BETWEEN 1 AND 2;
--
-- (b) Duplicate pairs. Two DISTINCT legacy 1:1 conversations in one workspace
-- that resolve to the same sorted pair both satisfy (a), but only one can be
-- reserved - the second violates dm_conversation_reservations_pair_unique and
-- aborts the migration. This is exactly what a pre-deterministic id scheme
-- leaves behind, so it is the likelier of the two in practice.
--
-- SELECT workspace_id, participant_one_id, participant_two_id,
-- COUNT(*) AS conversations
-- FROM (
-- SELECT dc.workspace_id,
-- MIN(dp.agent_id) AS participant_one_id,
-- MAX(dp.agent_id) AS participant_two_id
-- FROM dm_conversations dc
-- JOIN dm_participants dp ON dp.conversation_id = dc.id
-- WHERE dc.dm_type = '1:1'
-- GROUP BY dc.id, dc.workspace_id
-- HAVING COUNT(DISTINCT dp.agent_id) BETWEEN 1 AND 2
-- )
-- GROUP BY workspace_id, participant_one_id, participant_two_id
-- HAVING conversations > 1;
--
-- (c) Ids that do not match the CURRENT derivation. This one cannot be written
-- in SQL - it needs SHA-256, which SQLite does not have - and it is the check
-- that matters most operationally, because it is the only failure that is
-- INVISIBLE AT MIGRATION TIME. (a) and (b) abort the migration loudly. (c)
-- lets it succeed, and then every subsequent DM between that pair returns 409
-- forever, because the backfill reserved the pair under an id the send path
-- will never re-derive.
--
-- Run: node scripts/audit-dm-reservations.mjs --sqlite <path>
-- D1: wrangler d1 execute <DB> --json --command "<see script header>" \
-- | node scripts/audit-dm-reservations.mjs --stdin
--
-- That script also re-runs (a) and (b), so it is the single command to trust.
--
-- All three clean means this migration will apply AND no existing pair will start
-- failing afterwards. Remediate anything any of them returns - for (b) that means
-- deciding which conversation survives, since the reservation can only bind one;
-- for (c) it means re-keying the conversation to the derived id, or seeding its
-- reservation under the derived id, before deploying the code that reserves.

-- Backfill ONLY conversations with exactly two distinct participants.
--
-- A one-row roster is ambiguous and MUST NOT be reserved. It looks like a
-- self-DM, but `dm_participants.agent_id` cascades on agent deletion, so a
-- perfectly ordinary two-party 1:1 collapses to a single row the moment one
-- participant's agent is deleted - while its id still encodes the ORIGINAL pair.
--
-- Reading those as (X, X) is wrong twice over. Several orphans belonging to the
-- same surviving agent all collapse to the same (workspace, X, X) tuple and
-- collide on the pair-uniqueness index, aborting the migration; and any that
-- survived would reserve a self-DM tuple against an id no derivation produces,
-- so that agent's next self-DM would 409 forever.
--
-- This is not hypothetical. An earlier version of this backfill used
-- MIN/MAX over 1-2 participants; audited against production it produced 4
-- colliding pair groups and 30 mismatched ids, all of them orphaned two-party
-- conversations, and would have failed the deployment. Restricted to exactly two
-- participants the same data yields zero findings across 3425 conversations.
--
-- Skipping is safe rather than merely convenient. An unreserved conversation is
-- in exactly the state every conversation was in before this migration: the
-- first send through the reservation path claims it, and because a genuine
-- self-DM's id already equals its derivation, that claim adopts the existing
-- conversation instead of creating a second one. Orphaned two-party rows are
-- simply never re-derived, so they stay readable and inert.
--
-- Malformed rosters (zero, or more than two) are skipped for the same reason.
-- The earlier version aborted the whole migration on them; skipping avoids
-- inventing a tuple just as effectively without blocking a deployment, and any
-- future send still goes through the reservation path.
INSERT INTO dm_conversation_reservations (
conversation_id,
workspace_id,
participant_one_id,
participant_two_id,
created_at
)
SELECT
dc.id,
dc.workspace_id,
MIN(dp.agent_id),
MAX(dp.agent_id),
dc.created_at
FROM dm_conversations dc
JOIN dm_participants dp ON dp.conversation_id = dc.id
WHERE dc.dm_type = '1:1'
GROUP BY dc.id, dc.workspace_id, dc.created_at
HAVING COUNT(DISTINCT dp.agent_id) = 2;
28 changes: 28 additions & 0 deletions packages/engine/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
uniqueIndex,
primaryKey,
foreignKey,
check,
} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core';
Expand Down Expand Up @@ -602,6 +603,33 @@ export const dmConversations = sqliteTable(
],
);

// ============================================
// 1:1 DM Conversation Reservations
// ============================================
export const dmConversationReservations = sqliteTable(
'dm_conversation_reservations',
{
conversationId: text('conversation_id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspaces.id, { onDelete: 'cascade' }),
participantOneId: text('participant_one_id').notNull(),
participantTwoId: text('participant_two_id').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
},
(table) => [
check(
'dm_conversation_reservations_sorted_pair_check',
sql`${table.participantOneId} <= ${table.participantTwoId}`,
),
uniqueIndex('dm_conversation_reservations_pair_unique').on(
table.workspaceId,
table.participantOneId,
table.participantTwoId,
),
],
);

Comment on lines +606 to +632

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether dm_participants (and other agent-referencing tables) enforce an FK to agents, to compare with dmConversationReservations.
ast-grep run --pattern 'agentId: text($_).notNull().references($_)' --lang typescript packages/engine/src/db/schema.ts
rg -n -B3 -A3 'agentId' packages/engine/src/db/schema.ts | rg -n -B3 -A3 'dm_participants|dmParticipants'

Repository: AgentWorkforce/relaycast

Length of output: 373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== schema agents and dm_participants/dmConversationReservations sections ==="
sed -n '520,660p' packages/engine/src/db/schema.ts

echo
echo "=== FK references to agents.id in schema ==="
rg -n '\.references\(\(\) => agents\.id|agentId[^:]*text\([^)]*\)\.notNull\(\)(\.[^;}]+)?\.references|agent_id' packages/engine/src/db/schema.ts

echo
echo "=== dm_participants table usage / delete paths ==="
rg -n 'dm_participants|dmParticipants|agentId|deleteFrom|where.*agentId|agentId.*where' packages/engine/src -g '*.ts'

Repository: AgentWorkforce/relaycast

Length of output: 50380


Add a composite FK that enforces reserved DM pairs match dmParticipants rows.

dm_conversation_reservations.participantOneId and participantTwoId are not linked to dm_participants, while dmParticipants.agentId has a foreign key to agents.id. Add a SQL check/query constraint that each sorted pair exists in dm_participants for the same workspace_id, so deleted participants are not silently reclaimed as available valid reservation rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/db/schema.ts` around lines 606 - 632, Add a composite
foreign-key constraint to dmConversationReservations linking workspaceId,
participantOneId, and participantTwoId to the corresponding sorted-pair key on
dmParticipants, ensuring each reservation matches an existing pair in the same
workspace. Add or reuse the required unique composite key on dmParticipants, and
preserve the existing sorted-pair check and uniqueness constraint.

// ============================================
// DM Participants
// ============================================
Expand Down
Loading
Loading