From 1324316feeca7e613eafefde378a949bb792ed37 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:32:48 +0530 Subject: [PATCH] fix(signals): source order follows agent selection, not chronological timestamps Remove the OutOfOrderSource constraint from composeSignal. The agent's selection order is authoritative for source ordinals; timestamps remain as per-source provenance metadata. This resolves the contract conflict where reversed message selections (valid agent compositions) were rejected by the primitive. Each source still retains its original createdAt, so chronological provenance is preserved exactly. The sourceKey includes ordered message IDs, so different orderings produce distinct signals with deterministic idempotency. Coverage added: - Primitive: non-chronological order accepted, timestamps preserved - Backend: reversed selection preserves ordinals and exact raw text --- packages/backend/convex/signals.test.ts | 63 +++++++++++++++++++++++++ packages/primitives/src/signal.test.ts | 17 +++++-- packages/primitives/src/signal.ts | 19 ++------ 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/packages/backend/convex/signals.test.ts b/packages/backend/convex/signals.test.ts index 6d1e508..b14e092 100644 --- a/packages/backend/convex/signals.test.ts +++ b/packages/backend/convex/signals.test.ts @@ -412,6 +412,69 @@ describe("signals", () => { expect(list).toHaveLength(2); }); + test("reversed selection preserves agent ordinals and original timestamps", async () => { + const t = newTest(); + const orgId = await ensureOrg(t, identityA); + const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first"); + const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second"); + + // Chronological selection: m1 then m2. + const chrono = await t + .withIdentity(identityA) + .mutation(api.signals.createFromMessages, { + conversationId: orgId, + messageIds: [m1, m2], + organizationId: orgId, + problemStatement, + processedBy, + }); + + // Reversed selection: m2 then m1 (non-chronological agent order). + const reversed = await t + .withIdentity(identityA) + .mutation(api.signals.createFromMessages, { + conversationId: orgId, + messageIds: [m2, m1], + organizationId: orgId, + problemStatement, + processedBy, + }); + + expect(reversed.signalId).not.toBe(chrono.signalId); + + const chronoSignal = await t + .withIdentity(identityA) + .query(api.signals.get, { signalId: chrono.signalId }); + const reversedSignal = await t + .withIdentity(identityA) + .query(api.signals.get, { signalId: reversed.signalId }); + + // Chronological: ordinals follow selection. + expect(chronoSignal?.sources[0]?.messageId).toBe(m1); + expect(chronoSignal?.sources[0]?.ordinal).toBe(0); + expect(chronoSignal?.sources[1]?.messageId).toBe(m2); + expect(chronoSignal?.sources[1]?.ordinal).toBe(1); + + // Reversed: ordinals follow the agent's reversed selection, and each + // source retains its original creation timestamp (unchanged by reordering). + expect(reversedSignal?.sources[0]?.messageId).toBe(m2); + expect(reversedSignal?.sources[0]?.ordinal).toBe(0); + expect(reversedSignal?.sources[1]?.messageId).toBe(m1); + expect(reversedSignal?.sources[1]?.ordinal).toBe(1); + + // Exact raw text is preserved in both orderings. + expect( + chronoSignal?.sources.map( + (s: { rawTextSnapshot: string }) => s.rawTextSnapshot + ) + ).toEqual(["first", "second"]); + expect( + reversedSignal?.sources.map( + (s: { rawTextSnapshot: string }) => s.rawTextSnapshot + ) + ).toEqual(["second", "first"]); + }); + test("creates a project-scoped signal when the project owner is an org member", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); diff --git a/packages/primitives/src/signal.test.ts b/packages/primitives/src/signal.test.ts index e767463..0fad741 100644 --- a/packages/primitives/src/signal.test.ts +++ b/packages/primitives/src/signal.test.ts @@ -223,11 +223,22 @@ describe("composeSignal", () => { getValidationError(input, "MixedConversation"); }); - it("rejects decreasing source timestamps", () => { + it("accepts non-chronological source order with preserved timestamps", () => { const input = makeSignalInput(); - input.sourceMessages[1].createdAt = 99; + // Agent selects the later message first (logical, not chronological order). + const [earlier, later] = input.sourceMessages; + input.sourceMessages = [later, earlier]; - getValidationError(input, "OutOfOrderSource"); + const signal = runSignal(input); + + // Ordinal follows the agent's selection, not timestamps. + expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([ + "message-2", + "message-1", + ]); + // Original timestamps are preserved exactly on each source. + const timestamps = signal.sourceMessages.map(({ createdAt }) => createdAt); + expect(timestamps).toEqual([200, 100]); }); it("preserves source order and exact raw whitespace", () => { diff --git a/packages/primitives/src/signal.ts b/packages/primitives/src/signal.ts index ceb1f30..fc4c8ec 100644 --- a/packages/primitives/src/signal.ts +++ b/packages/primitives/src/signal.ts @@ -92,7 +92,6 @@ export const SignalValidationReason = Schema.Literals([ "InvalidInput", "DuplicateSource", "MixedConversation", - "OutOfOrderSource", ]); export type SignalValidationReason = typeof SignalValidationReason.Type; @@ -118,9 +117,11 @@ export const composeSignal = Effect.fn("Signal.compose")( ) ); - const [{ conversationId, createdAt: firstCreatedAt }] = - signal.sourceMessages; - let previousCreatedAt = firstCreatedAt; + // Source order follows the agent's logical selection, not chronological + // timestamp order. Each source retains its original createdAt so temporal + // provenance is preserved exactly; the ordinal array position reflects the + // agent's narrative composition of the problem statement. + const [{ conversationId }] = signal.sourceMessages; const messageIds = new Set(); for (const source of signal.sourceMessages) { @@ -142,16 +143,6 @@ export const composeSignal = Effect.fn("Signal.compose")( ); } messageIds.add(source.messageId); - - if (source.createdAt < previousCreatedAt) { - return yield* Effect.fail( - new SignalValidationError({ - message: "Signal source timestamps must be non-decreasing", - reason: "OutOfOrderSource", - }) - ); - } - previousCreatedAt = source.createdAt; } return signal;