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
This commit is contained in:
-Puter
2026-07-24 20:32:48 +05:30
parent b5c40755cf
commit 1324316fee
3 changed files with 82 additions and 17 deletions

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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<ConversationMessageId>();
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;