Skip to content
Open
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
109 changes: 109 additions & 0 deletions src/domain/analysisView/environmentStateWriteExplain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,115 @@ it.each([
expect(randomIdentities(members, "id", ["at"])).toBe(random);
});

it.each([
[undefined, undefined, undefined],
["2026-03-01T00:00:00Z", undefined, undefined],
[undefined, "2026-03-02T00:00:00Z", "2026-03-03T00:00:00Z"],
])(
"does not treat missing creation clocks as identity inversions: %s",
(...clocks) => {
const members = clocks.map((at, index) => {
const member: JsonObject = { id: `id-${String(index)}` };
if (at !== undefined) member.at = at;
return member;
});
expect(randomIdentities(members, "id", ["at"])).toBe(false);
},
);

it("still recognizes a witnessed identity inversion when another clock value is missing", () => {
expect(
randomIdentities(
[
{ id: "id-0", at: "2026-03-02T00:00:00Z" },
{ id: "id-1", at: "2026-03-01T00:00:00Z" },
{ id: "id-2" },
],
"id",
["at"],
),
).toBe(true);
});

it.each([undefined, null, false, {}] as const)(
"recognizes a real inversion across a noncomparable clock: %s",
(gap) => {
const middle: JsonObject = { id: "id-1" };
if (gap !== undefined) middle.at = gap;
expect(
randomIdentities(
[
{ id: "id-0", at: "2026-03-02T00:00:00Z" },
middle,
{ id: "id-2", at: "2026-03-01T00:00:00Z" },
],
"id",
["at"],
),
).toBe(true);
},
);

it("keeps a known ordered clock from classifying IDs as random across another clock's gap", () => {
expect(
randomIdentities(
[
{
id: "id-0",
at: "2026-03-02T00:00:00Z",
other: "2026-03-01T00:00:00Z",
},
{ id: "id-1" },
{
id: "id-2",
at: "2026-03-01T00:00:00Z",
other: "2026-03-02T00:00:00Z",
},
],
"id",
["at", "other"],
),
).toBe(false);
});

it.each([
[1772409602, "not-a-clock", 1772409601],
["2026-03-02T00:00:00Z", "not-a-clock", "2026-03-01T00:00:00Z"],
[1772409602, "1772409600", 1772409601],
])(
"keeps invalid clock text from hiding a known inversion: %s",
(first, gap, last) => {
expect(
randomIdentities(
[
{ id: "id-0", at: first! },
{ id: "id-1", at: gap! },
{ id: "id-2", at: last! },
],
"id",
["at"],
),
).toBe(true);
},
);

it.each([
[1772409602, 1772409601000, true],
[1772409602000, 1772409602, false],
["2026-03-01T00:00:00.123000002Z", "2026-03-01T00:00:00.123000001Z", true],
] as const)(
"compares creation clocks by their instants across gaps and units: %s",
(first, last, random) => {
expect(
randomIdentities(
[{ id: "id-0", at: first }, { id: "id-1" }, { id: "id-2", at: last }],
"id",
["at"],
),
).toBe(random);
},
);

it("uses retained submillisecond precision to recognize and place sorted timestamps", () => {
const members = [
{ at: "2026-03-04T05:00:00.123000002Z" },
Expand Down
32 changes: 19 additions & 13 deletions src/domain/analysisView/environmentStateWriteExplain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
slugSpellings,
sortedStrings,
type ClockFormat,
type ClockReading,
} from "./environmentStateWriteValues.js";
import {
composedValue,
Expand Down Expand Up @@ -664,6 +665,11 @@ export const responseShapeOf = (
return { ordinal, shape: "recorded", pointer: null };
};

const compareClockReadings = (
left: ClockReading,
right: ClockReading,
): number => left.ms - right.ms || left.subMillisecond - right.subMillisecond;

/** Order two field values as numbers, instants or text; undefined when they are not comparable. */
export const compareValues = (
left: JsonValue | undefined,
Expand All @@ -672,8 +678,7 @@ export const compareValues = (
const [a, b] = [integerOf(left), integerOf(right)];
if (a !== undefined && b !== undefined) return a - b;
const [c, d] = [readClock(left), readClock(right)];
if (c !== undefined && d !== undefined)
return c.ms - d.ms || c.subMillisecond - d.subMillisecond;
if (c !== undefined && d !== undefined) return compareClockReadings(c, d);
return typeof left === "string" && typeof right === "string"
? compare(left, right)
: undefined;
Expand Down Expand Up @@ -702,7 +707,8 @@ const UUID_V4 =

/**
* Whether a collection's identities order nothing: version 4 UUIDs, or values every creation clock
* lists in another order. Time-ordered ids (ObjectId, ULID, TSID, version 7 UUIDs) follow a clock.
* lists in another order. Only recognized clock observations establish that order.
* Time-ordered ids (ObjectId, ULID, TSID, version 7 UUIDs) follow a clock.
*/
export const randomIdentities = (
members: readonly JsonObject[],
Expand All @@ -718,16 +724,16 @@ export const randomIdentities = (
(left, right) =>
compareValues(ownMember(left, idField), ownMember(right, idField)) ?? 0,
);
return clocks.every((clock) =>
byId.some(
(member, index) =>
index > 0 &&
(compareValues(
ownMember(byId[index - 1]!, clock),
ownMember(member, clock),
) ?? 1) > 0,
),
);
return clocks.every((clock) => {
const values = byId.flatMap((member) => {
const reading = readClock(ownMember(member, clock));
return reading === undefined ? [] : [reading];
});
return values.some((value, index) => {
if (index === 0) return false;
return compareClockReadings(values[index - 1]!, value) > 0;
});
});
};

/**
Expand Down
144 changes: 144 additions & 0 deletions tests/acceptance/environmentSparseClockWritesMcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import { expect } from "vitest";
import { z } from "zod";
import { jsonObjectSchema } from "../../src/domain/jsonValue.js";
import {
timedCaptureEvidence,
type TimedCaptureRow,
} from "../fixtures/analysisView.js";
import {
environmentStateServeFixture,
writeEnvironmentServeFiles,
} from "../fixtures/environmentServe.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { startListeningProcess } from "../support/mcp/streamableHttpProcess.js";
import { processTest } from "../support/process/processFixture.js";

const rowsWithClocks = (
mode:
| "sparse"
| "complete"
| "no-clock"
| "gapped-inversion"
| "invalid-clock-gap"
| "mixed-unit-gap",
): TimedCaptureRow[] => {
const members: { id: string; name: string; created_at?: string | number }[] =
["24", "25", "26"].map((suffix, index) => ({
id: `6ad47b491023b500010871${suffix}`,
name: ["Delta", "Alpha", "Charlie"][index]!,
...(mode === "complete" || (mode === "sparse" && index === 0)
? {
created_at: `2026-10-01T${String(9 + index).padStart(2, "0")}:00:00Z`,
}
: {}),
}));
if (mode === "gapped-inversion") {
members[0]!.created_at = "2026-10-01T11:00:00Z";
members[2]!.created_at = "2026-10-01T09:00:00Z";
}
if (mode === "invalid-clock-gap") {
members[0]!.created_at = 1772409602;
members[1]!.created_at = "not-a-clock";
members[2]!.created_at = 1772409601;
}
if (mode === "mixed-unit-gap") {
members[0]!.created_at = 1772409602;
members[2]!.created_at = 1772409601000;
}
const created = {
id: "6ad47b491023b50001087130",
name: "Echo",
...(mode === "no-clock" ? {} : { created_at: "2026-10-17T18:00:04Z" }),
};
return [
["GET", "/api/items", members],
...members.map((member): TimedCaptureRow => [
"GET",
`/api/items/${member.id}`,
member,
]),
["POST", "/api/items", created, 201, { name: "Echo" }],
["GET", `/api/items/${created.id}`, created],
];
};

const checkCreatedItems = async (
client: Client,
appended: boolean,
): Promise<void> => {
for (const name of ["First new item", "Second new item", "Third new item"]) {
const created = await client.callTool({
name: "post_items",
arguments: { body: { name } },
});
expect(created.isError).toBeFalsy();
}
const reply = await client.callTool({ name: "get_items", arguments: {} });
expect(reply.isError).toBeFalsy();
const items = z
.array(z.object({ id: z.string(), name: z.string() }))
.parse(jsonObjectSchema.parse(reply.structuredContent).body);
expect(items).toHaveLength(6);
if (appended)
expect(items.map(({ name }) => name)).toEqual([
"Delta",
"Alpha",
"Charlie",
"First new item",
"Second new item",
"Third new item",
]);
else {
const ids = items.map(({ id }) => id);
expect(ids).toEqual([...ids].sort());
}
};

for (const [mode, appended] of [
["sparse", false],
["complete", false],
["no-clock", false],
["gapped-inversion", true],
["invalid-clock-gap", true],
["mixed-unit-gap", true],
] as const)
processTest(
`${appended ? "appends writes" : "keeps ID-sorted writes"} with ${mode} creation clocks over HTTP MCP`,
async ({ processes }) => {
const fixture = environmentStateServeFixture(
timedCaptureEvidence(rowsWithClocks(mode)),
);
expect(fixture.state.normalized_result).toMatchObject({
writes: expect.arrayContaining([
expect.objectContaining({
tool: "post_items",
served: true,
insert: appended
? { rule: "last" }
: { rule: "sorted", field: "id", direction: "ascending" },
}),
]),
});
const root = await createTestTempDirectory("rea-sparse-clock-writes-");
const files = await writeEnvironmentServeFiles(root, fixture);
const started = await startListeningProcess(processes, [
"environment-serve",
files.manifestPath,
"--evidence",
files.capturePath,
"--port",
"0",
]);
const client = new Client({ name: "sparse-clock-writes", version: "1" });
try {
await client.connect(new StreamableHTTPClientTransport(started.url));
await checkCreatedItems(client, appended);
} finally {
await client.close();
}
},
);
Loading