feat(broker): record daemon idempotency fields on message_queue
Additive plumbing for v0.9.0 daemon spec §4.2/§4.4. Adds two nullable
columns to mesh.message_queue — client_message_id (caller-supplied) and
request_fingerprint (canonical sha256 of the send shape) — and threads
them through the broker:
- handleSend reads them off the wire envelope when present
- queueMessage persists them on the row
- drainForMember projects them onto the push so receiving daemons
can dedupe their local inbox by client_message_id
Columns stay nullable so legacy traffic (launch CLI, dashboard chat)
continues to flow uninterrupted. Sprint 7 (broker hardening) will add
the partial unique index and the client_message_dedupe atomic-accept
table once we're ready to enforce dedupe broker-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2261,6 +2261,10 @@ export interface QueueParams {
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
expiresAt?: Date;
|
||||
/** Daemon idempotency id (spec §4.2). Optional; pre-daemon callers omit. */
|
||||
clientMessageId?: string;
|
||||
/** Canonical request fingerprint hex (spec §4.4). Optional; pre-daemon callers omit. */
|
||||
requestFingerprint?: string;
|
||||
}
|
||||
|
||||
/** Insert an E2E envelope into the mesh's message queue. */
|
||||
@@ -2276,6 +2280,8 @@ export async function queueMessage(params: QueueParams): Promise<string> {
|
||||
nonce: params.nonce,
|
||||
ciphertext: params.ciphertext,
|
||||
expiresAt: params.expiresAt,
|
||||
clientMessageId: params.clientMessageId ?? null,
|
||||
requestFingerprint: params.requestFingerprint ?? null,
|
||||
})
|
||||
.returning({ id: messageQueue.id });
|
||||
if (!row) throw new Error("failed to queue message");
|
||||
@@ -2323,6 +2329,9 @@ export async function drainForMember(
|
||||
createdAt: Date;
|
||||
senderMemberId: string;
|
||||
senderPubkey: string;
|
||||
/** v0.9.0 daemon fields; null for legacy traffic. */
|
||||
clientMessageId: string | null;
|
||||
requestFingerprint: string | null;
|
||||
}>
|
||||
> {
|
||||
const priorities = deliverablePriorities(status);
|
||||
@@ -2384,6 +2393,8 @@ export async function drainForMember(
|
||||
created_at: string | Date;
|
||||
sender_member_id: string;
|
||||
sender_pubkey: string;
|
||||
client_message_id: string | null;
|
||||
request_fingerprint: string | null;
|
||||
}>(sql`
|
||||
WITH claimed AS (
|
||||
UPDATE mesh.message_queue AS mq
|
||||
@@ -2402,6 +2413,7 @@ export async function drainForMember(
|
||||
AND m.id = mq.sender_member_id
|
||||
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
|
||||
mq.created_at, mq.sender_member_id,
|
||||
mq.client_message_id, mq.request_fingerprint,
|
||||
COALESCE(mq.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey
|
||||
)
|
||||
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
|
||||
@@ -2415,6 +2427,8 @@ export async function drainForMember(
|
||||
created_at: string | Date;
|
||||
sender_member_id: string;
|
||||
sender_pubkey: string;
|
||||
client_message_id: string | null;
|
||||
request_fingerprint: string | null;
|
||||
}>;
|
||||
if (!rows || rows.length === 0) return [];
|
||||
return rows.map((r) => ({
|
||||
@@ -2426,6 +2440,8 @@ export async function drainForMember(
|
||||
r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
|
||||
senderMemberId: r.sender_member_id,
|
||||
senderPubkey: r.sender_pubkey,
|
||||
clientMessageId: r.client_message_id ?? null,
|
||||
requestFingerprint: r.request_fingerprint ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +564,8 @@ async function maybePushQueuedMessages(
|
||||
nonce: m.nonce,
|
||||
ciphertext: m.ciphertext,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
...(m.clientMessageId ? { client_message_id: m.clientMessageId } : {}),
|
||||
...(m.requestFingerprint ? { request_fingerprint: m.requestFingerprint } : {}),
|
||||
};
|
||||
sendToPeer(presenceId, push);
|
||||
metrics.messagesRoutedTotal.inc({ priority: m.priority });
|
||||
@@ -1968,6 +1970,12 @@ async function handleSend(
|
||||
}
|
||||
}
|
||||
|
||||
// v0.9.0 daemon clients attach a stable idempotency id and the canonical
|
||||
// request fingerprint per spec §4.2/§4.4. Forward both verbatim; legacy
|
||||
// callers omit them and the columns are nullable.
|
||||
const clientMessageId = (msg as { client_message_id?: string }).client_message_id;
|
||||
const requestFingerprint = (msg as { request_fingerprint?: string }).request_fingerprint;
|
||||
|
||||
const messageId = await queueMessage({
|
||||
meshId: conn.meshId,
|
||||
senderMemberId: conn.memberId,
|
||||
@@ -1976,6 +1984,8 @@ async function handleSend(
|
||||
priority: msg.priority,
|
||||
nonce: msg.nonce,
|
||||
ciphertext: msg.ciphertext,
|
||||
clientMessageId: clientMessageId && clientMessageId.length > 0 ? clientMessageId : undefined,
|
||||
requestFingerprint: requestFingerprint && requestFingerprint.length > 0 ? requestFingerprint : undefined,
|
||||
});
|
||||
|
||||
// Topic-tagged messages (targetSpec starts with `#<topicId>`) get
|
||||
|
||||
@@ -139,6 +139,12 @@ export interface WSPushMessage {
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
/** v0.9.0 daemon fields. Echoed when the sender's send envelope
|
||||
* carried them (spec §4.2/§4.4). Receivers use `client_message_id`
|
||||
* for idempotent inbox dedupe and `request_fingerprint` for
|
||||
* defense-in-depth verification. Both null on legacy traffic. */
|
||||
client_message_id?: string | null;
|
||||
request_fingerprint?: string | null;
|
||||
/** Optional semantic tag — "reminder" when delivered by the scheduler,
|
||||
* "system" for broker-originated topology events (peer join/leave). */
|
||||
subtype?: "reminder" | "system";
|
||||
|
||||
Reference in New Issue
Block a user