fix(environment): preserve clock years and exact probe chronology

Reuse the shared offset-instant owner for calendar validation and exact probe ordering. Preserve first-century years, arbitrary recorded fraction digits in the supported offset profile, ordinal ties and legacy native-Date fallback. Keep original clock spelling when conversion leaves the supported four-digit year range, and refuse unrepresentable generated clocks before committing state.

Validation: focused clock/comparison and public CLI regressions cover leap years, offset equivalence, precise DELETE/GET order, spelling classification and refusal atomicity. Replay/state compatibility checks pass. Numeric state intervals and generated-clock progression retain their documented millisecond limits.

Fixes #6835
Fixes #6643
This commit is contained in:
morluto
2026-10-24 02:50:11 +08:00
parent 15190ffc49
commit 0bbd635ab8
9 changed files with 383 additions and 72 deletions
+10 -1
View File
@@ -774,7 +774,16 @@ or `page`. Each call is checked against the served tool's input schema first, as
`at`, `time`, `date`, `timestamp`, `created`, `updated` or `modified` field names, including snake-case and camel-case forms), and two different values each side generated on its own compare equal: both UUIDs, or one length of
eight or more with the same character classes and a digit. The report counts each kind of normalised value.
ISO clock offsets are accepted only within RFC 3339's `00`–`23` hour and `00`–`59` minute ranges.
Timestamp ordering retains submillisecond precision when the source records it.
Probe ordering retains every recorded fractional-second digit in ISO timestamps
with a four-digit year, `T`, and a `Z` or `±HH:MM` suffix. Equal instants use ordinal order. Other native-Date
spellings keep their existing millisecond interpretation; an absent or unreadable
start time makes the whole probe use record order. Original capture timestamps
remain unchanged. Clock-format comparison and respelling recognize four-digit
ISO years `0000`–`0099` as well as later years, with normal calendar validation.
Respelling preserves the original value when the target zone would require a year
outside `0000`–`9999`; generated writes refuse a clock outside that format.
Capture-state extraction still uses millisecond coordinates for request intervals
and their ordering, and generated-clock progression compares milliseconds.
Comparison keeps response representations distinct: JSON values compare structurally, text compares as text, and a
retained base64 body compares by bytes (including with UTF-8 text carrying those same bytes), never by its base64
@@ -36,7 +36,7 @@ import {
parameterScopes,
} from "./environmentCompareMatch.js";
import type { EnvironmentManifestTool } from "./environmentSource.js";
import { isCut } from "./environmentCompareProbe.js";
import { isCut, probeRequests } from "./environmentCompareProbe.js";
import { parseCaptureEvidence } from "./environmentSource.js";
const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS));
@@ -644,29 +644,99 @@ describe("browser probes", () => {
});
});
describe("what a probe did not observe", () => {
const at = (second: number): string =>
new Date(
Date.parse("2026-10-20T09:00:00.000Z") + second * 1000,
).toISOString();
const exchange = (
second: number,
method: string,
url: string | null,
status: number,
) => ({
startedDateTime: at(second),
request: { method, url },
response: { status, content: { mimeType: "application/json" } },
const at = (second: number): string =>
new Date(
Date.parse("2026-10-20T09:00:00.000Z") + second * 1000,
).toISOString();
const exchange = (
second: number,
method: string,
url: string | null,
status: number,
) => ({
startedDateTime: at(second),
request: { method, url },
response: { status, content: { mimeType: "application/json" } },
});
const run = (value: unknown) =>
compareEnvironment({
source,
captureValue: fixture.capture,
probes: [{ label: "probe-1", value }],
operation: "environment-compare",
});
const run = (value: unknown) =>
compareEnvironment({
source,
captureValue: fixture.capture,
probes: [{ label: "probe-1", value }],
operation: "environment-compare",
});
describe("probe request chronology", () => {
it.each([
["2026-10-20T09:00:00.0002Z", "2026-10-20T09:00:00.0001Z"],
["2026-10-20T09:00:00.0000000002Z", "2026-10-20T09:00:00.0000000001Z"],
["0001-01-01T00:00:00.0002Z", "0001-01-01T01:00:00.0001+01:00"],
])(
"replays an earlier delete before a later submillisecond read (%s)",
(later, earlier) => {
const report = run(
analysisViewCaptureEvidence(
environmentManifestCaptureFixture([
{
...exchange(1, "GET", "http://app.example/api/books/2", 404),
startedDateTime: later,
response: {
status: 404,
content: {
mimeType: "application/json",
text: JSON.stringify(BOOK_NOT_FOUND),
},
},
},
{
...exchange(0, "DELETE", "http://app.example/api/books/2", 204),
startedDateTime: earlier,
},
]),
),
);
if (!report.ok) throw report.error;
expect(
report.value.probes[0]?.results.map(({ ordinal, env_status }) => [
ordinal,
env_status,
]),
).toEqual([
[1, 204],
[0, 404],
]);
expect(report.value.summary).toMatchObject({
steps: 2,
equal: 2,
agreement: 1,
});
},
);
it.each([
["2026-10-20T09:00:00.000100Z", "2026-10-20T10:00:00.0001+01:00"],
["Tue, 20 Oct 2026 09:00:00 GMT", "2026-10-20T09:00:00Z"],
])(
"keeps ordinal order for equal instants and native-Date spelling compatibility (%s)",
(left, right) => {
const capture = environmentManifestCaptureFixture([
{
...exchange(0, "GET", "http://app.example/api/books/1", 200),
startedDateTime: left,
},
{
...exchange(1, "GET", "http://app.example/api/books/2", 200),
startedDateTime: right,
},
]);
expect(
probeRequests(capture.records).steps.map(({ ordinal }) => ordinal),
).toEqual([0, 1]);
},
);
});
describe("what a probe did not observe", () => {
it("compares an unrecorded body by status only and lists the records it could not compare", () => {
const report = run(
analysisViewCaptureEvidence(
@@ -16,6 +16,21 @@ const compare = (
map,
);
describe("early-year clock formats", () => {
it.each(["0000", "0001", "0099", "0100"])(
"classifies a year-%s clock spelling difference by format",
(year) => {
const result = compare(
{ created_at: `${year}-01-01T00:00:00Z` },
{ created_at: `${year}-01-01 00:00:00.000Z` },
);
expect(
result.differences.map(({ category, pointer }) => [category, pointer]),
).toEqual([["clock-format", "/created_at"]]);
},
);
});
describe("compareAnswers", () => {
it("agrees on mapped ids, clocks of one format and generated values, and counts each", () => {
const map = new IdMap();
@@ -12,6 +12,13 @@ import {
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
import { parseExactJson } from "../../domain/exactJsonNumber.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import {
compareOffsetDateTimeInstants,
offsetDateTimeInstantFromEpochMilliseconds,
offsetDateTimeSchema,
parseOffsetDateTimeInstant,
type OffsetDateTimeInstant,
} from "../../domain/offsetDateTime.js";
import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js";
import type { Answer } from "./environmentCompareDiff.js";
import type { ProbeRequest } from "./environmentCompareMatch.js";
@@ -46,10 +53,20 @@ const postDataSchema = z.object({
const methodSchema = z.object({ request: z.object({ method: z.string() }) });
const startedMs = (record: WebNetworkCaptureRecord): number | undefined => {
const startedInstant = (
record: WebNetworkCaptureRecord,
): OffsetDateTimeInstant | undefined => {
const started = startedSchema.safeParse(record.reported);
const ms = started.success ? Date.parse(started.data.startedDateTime) : NaN;
return Number.isFinite(ms) ? ms : undefined;
if (!started.success) return undefined;
const timestamp = offsetDateTimeSchema.safeParse(
started.data.startedDateTime,
);
if (timestamp.success) return parseOffsetDateTimeInstant(timestamp.data);
// Preserve native-Date admission for legacy spellings, at its original precision.
const ms = Date.parse(started.data.startedDateTime);
return Number.isFinite(ms)
? offsetDateTimeInstantFromEpochMilliseconds(ms)
: undefined;
};
/** Whether a probe makes calls that carry a request body: a POST, PUT or PATCH. */
@@ -118,13 +135,17 @@ export const probeRequests = (
const unobserved: UnobservedRecord[] = [];
// Without a start time for every record the probe keeps its recorded order, as state extraction does.
const starts = new Map(
records.map((record) => [record.ordinal, startedMs(record)]),
records.map((record) => [record.ordinal, startedInstant(record)]),
);
const timed = [...starts.values()].every((ms) => ms !== undefined);
const timed = [...starts.values()].every((instant) => instant !== undefined);
const ordered = [...records].sort(
(left, right) =>
(timed ? starts.get(left.ordinal)! - starts.get(right.ordinal)! : 0) ||
left.ordinal - right.ordinal,
(timed
? compareOffsetDateTimeInstants(
starts.get(left.ordinal)!,
starts.get(right.ordinal)!,
)
: 0) || left.ordinal - right.ordinal,
);
const lines = new Map(
records.map((record) => [
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
analysisViewCaptureEvidence,
@@ -183,6 +183,23 @@ describe("confirmed write response bodies", () => {
);
});
it("refuses an unrepresentable generated clock without changing the held state", () => {
const books = servedFrom();
const pending = books.store.pendingWrite();
const pendingWrite = books.store.pendingWrite.bind(books.store);
vi.spyOn(books.store, "pendingWrite").mockImplementation(() => ({
...pendingWrite(),
clockMs: Date.parse("0000-01-01T00:00:00+01:00"),
}));
const before = books.snapshot();
const refusal = books.refusal("post_books", CREATE_NEW_BOOK);
expect(JSON.stringify(refusal)).toContain(
"outside its recorded four-digit year format",
);
expect(books.snapshot()).toBe(before);
expect(books.store.pendingWrite().count).toBe(pending.count);
});
describe("served creates", () => {
it("refuses an exhausted generated-value collision search without changing state", () => {
const existing = {
@@ -201,8 +201,14 @@ const generator = (
const seed = (attempt: number) =>
`${store.state.evidenceId}:${request.tool.name}:${rule.field}:${String(write.count)}:${String(attempt)}`;
switch (rule.rule) {
case "clock":
return ok(formatClock(write.clockMs, rule.format));
case "clock": {
const value = formatClock(write.clockMs, rule.format);
return value === undefined
? err(
`The generated clock for ${rule.field} is outside its recorded four-digit year format.`,
)
: ok(value);
}
case "sequence":
return ok(
spelledLike(
@@ -33,6 +33,27 @@ const seeds = (count: number): string[] =>
Array.from({ length: count }, (_, index) => `seed-${String(index)}`);
it.each<[string, number, ClockFormat, number?]>([
[
"0000-02-29T00:00:00.000123456Z",
Date.parse("0000-02-29T00:00:00Z"),
iso("T", 9, "Z"),
123456,
],
[
"0001-01-01T00:00:00Z",
Date.parse("0001-01-01T00:00:00Z"),
iso("T", 0, "Z"),
],
[
"0099-01-01T00:00:00Z",
Date.parse("0099-01-01T00:00:00Z"),
iso("T", 0, "Z"),
],
[
"0100-01-01T00:00:00Z",
Date.parse("0100-01-01T00:00:00Z"),
iso("T", 0, "Z"),
],
["2026-03-04T05:06:07Z", WHOLE_SECOND, iso("T", 0, "Z")],
["2026-03-04 05:06:07.123Z", WITH_MS, iso(" ", 3, "Z")],
["2026-03-04T05:06:07.123456Z", WITH_MS, iso("T", 6, "Z"), 456000],
@@ -53,6 +74,8 @@ it.each<[string, number, ClockFormat, number?]>([
);
it.each([
"0001-02-29T00:00:00Z",
"0100-02-29T00:00:00Z",
"2026-02-30T00:00:00Z",
"2026-02-29T00:00:00Z",
"2026-13-01T00:00:00Z",
@@ -72,6 +95,25 @@ it.each([
expect(readClock(text)).toBeUndefined();
});
it.each(["0000", "0001", "0099", "0100"])(
"respells year %s without a numeric-year century adjustment",
(year) => {
expect(respellClock(`${year}-01-01T00:00:00Z`, iso(" ", 3, "Z"))).toBe(
`${year}-01-01 00:00:00.000Z`,
);
},
);
it.each(["0000-01-01T00:00:00+01:00", "9999-12-31T23:59:59-01:00"])(
"keeps %s when conversion cannot fit the four-digit year format",
(value) => {
const clock = readClock(value)!;
expect(respellClock(value, iso("T", 0, "Z"))).toBe(value);
expect(formatClock(clock.ms, iso("T", 0, "Z"))).toBeUndefined();
expect(formatClock(clock.ms, clock.format)).toBe(value);
},
);
it.each<[JsonValue | undefined, ReturnType<typeof readClock>]>([
[
WHOLE_SECOND / 1000,
@@ -1,5 +1,9 @@
import { createHash } from "node:crypto";
import type { JsonValue } from "../jsonValue.js";
import {
offsetDateTimeSchema,
parseOffsetDateTimeInstant,
} from "../offsetDateTime.js";
/** How a recorded clock value is spelled: ISO-8601 with its separator, fraction digits and zone, or an epoch number. */
export type ClockFormat =
@@ -19,7 +23,7 @@ interface ClockReading {
}
const ISO_INSTANT =
/^(\d{4})-(\d{2})-(\d{2})([T ])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u;
/^\d{4}-\d{2}-\d{2}([T ])\d{2}:\d{2}:\d{2}(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u;
const MINUTE_MS = 60_000;
/** Minutes east of UTC that a zone suffix names; none and `Z` are UTC. */
@@ -49,34 +53,22 @@ const zoneMinutesForFormat = (zone: string): number => {
const readIsoClock = (value: string): ClockReading | undefined => {
const match = ISO_INSTANT.exec(value);
if (match === null) return undefined;
const [year, month, day, separator, hour, minute, second] = match
.slice(1, 8)
.map((part) => (part === "T" || part === " " ? part : Number(part)));
const fraction = match[8] ?? "";
const zone = match[9] ?? "";
const offsetMinutes = zoneMinutes(zone);
if (offsetMinutes === undefined) return undefined;
const local = Date.UTC(
year as number,
(month as number) - 1,
day as number,
hour as number,
minute as number,
second as number,
Number(`${fraction}000`.slice(0, 3)),
const [, separator, fraction = "", zone = ""] = match;
// Clock spelling also permits a space, compact offsets and implicit UTC.
// Normalize those spellings before the shared owner validates the calendar.
const offset =
zone === ""
? "Z"
: zone === "Z" || zone.includes(":")
? zone
: `${zone.slice(0, 3)}:${zone.slice(3)}`;
const timestamp = offsetDateTimeSchema.safeParse(
`${value.slice(0, 10)}T${value.slice(11, 19)}${fraction === "" ? "" : `.${fraction}`}${offset}`,
);
// Date.UTC rolls invalid fields over; the round trip refuses them.
const rolled = new Date(local);
if (
rolled.getUTCFullYear() !== year ||
rolled.getUTCMonth() !== (month as number) - 1 ||
rolled.getUTCDate() !== day ||
rolled.getUTCHours() !== hour ||
rolled.getUTCMinutes() !== minute
)
return undefined;
if (!timestamp.success) return undefined;
const instant = parseOffsetDateTimeInstant(timestamp.data);
return {
ms: local - offsetMinutes * MINUTE_MS,
ms: instant.epochSecond * 1000 + Number(`${fraction}000`.slice(0, 3)),
subMillisecond: Number(fraction.padEnd(9, "0").slice(3)),
format: {
kind: "iso",
@@ -118,11 +110,17 @@ const isoText = (
separator: string,
digits: string,
zone: string,
): string =>
`${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
): string | undefined => {
const year = at.getUTCFullYear();
if (!Number.isInteger(year) || year < 0 || year > 9999) return undefined;
return `${pad(year, 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
};
/** An instant spelled in a recorded clock format; fraction digits past milliseconds are zeros. */
export const formatClock = (ms: number, format: ClockFormat): JsonValue => {
/** An instant in a recorded format; undefined outside its four-digit ISO year range. Fraction digits past milliseconds are zeros. */
export const formatClock = (
ms: number,
format: ClockFormat,
): JsonValue | undefined => {
if (format.kind === "epoch")
return format.unit === "s" ? Math.floor(ms / 1000) : ms;
const at = new Date(ms + zoneMinutesForFormat(format.zone) * MINUTE_MS);
@@ -144,7 +142,7 @@ export const respellClock = (
): JsonValue => {
const clock = readClock(value);
if (clock === undefined) return value;
if (format.kind === "epoch") return formatClock(clock.ms, format);
if (format.kind === "epoch") return formatClock(clock.ms, format) ?? value;
const millis = ((clock.ms % 1000) + 1000) % 1000;
// An epoch value's fraction is its milliseconds, without trailing zeros.
const digits =
@@ -157,11 +155,13 @@ export const respellClock = (
const at = new Date(
clock.ms - millis + zoneMinutesForFormat(format.zone) * MINUTE_MS,
);
return isoText(
at,
format.separator,
digits.padEnd(format.fraction, "0"),
format.zone,
return (
isoText(
at,
format.separator,
digits.padEnd(format.fraction, "0"),
format.zone,
) ?? value
);
};
@@ -1,13 +1,18 @@
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { timedCaptureEvidence } from "../../fixtures/analysisView.js";
import {
analysisViewCaptureEvidence,
environmentManifestCaptureFixture,
timedCaptureEvidence,
} from "../../fixtures/analysisView.js";
import { environmentStateServeFixture } from "../../fixtures/environmentServe.js";
import {
BOOK_NOT_FOUND,
BOOK_ROWS,
FIRST_BOOK,
book,
} from "../../fixtures/environmentStateWrites.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -34,7 +39,133 @@ const writeCompareFiles = async (root: string) => {
return paths;
};
/** The exact chronology and clock-format probes share one unchanged source capture. */
const writeExactClockFiles = async (
root: string,
): Promise<Map<string, string>> => {
const capture = timedCaptureEvidence([
["GET", "/api/books", [FIRST_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["DELETE", "/api/books/1", "", 204],
["GET", "/api/books/1", BOOK_NOT_FOUND, 404],
]);
const fixture = environmentStateServeFixture(capture);
const ordered = analysisViewCaptureEvidence(
environmentManifestCaptureFixture([
{
startedDateTime: "0001-01-01T00:00:00.0000000002Z",
request: { method: "GET", url: "http://app.example/api/books/1" },
response: {
status: 404,
content: {
mimeType: "application/json",
text: JSON.stringify(BOOK_NOT_FOUND),
},
},
},
{
startedDateTime: "0001-01-01T01:00:00.0000000001+01:00",
request: {
method: "DELETE",
url: "http://app.example/api/books/1",
},
response: {
status: 204,
content: { mimeType: "application/json", text: "" },
},
},
]),
);
const clocks = timedCaptureEvidence([
[
"GET",
"/api/books/1",
{ ...FIRST_BOOK, created_at: "0001-01-01 00:00:00.000Z" },
],
]);
const originals = new Map<string, string>();
for (const [name, value] of [
["manifest.json", fixture.manifest],
["capture.json", capture],
["order.json", ordered],
["clocks.json", clocks],
] as const) {
const path = join(root, name);
const text = JSON.stringify(value);
originals.set(path, text);
await writeFile(path, text);
}
return originals;
};
describe("rea environment-compare", () => {
cliTest(
"preserves exact probe chronology and first-century clock-format differences",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-exact-clock-",
);
const originals = await writeExactClockFiles(root);
const result = await cli.run({
arguments: [
"environment-compare",
join(root, "manifest.json"),
"--evidence",
join(root, "capture.json"),
"--probe",
join(root, "order.json"),
"--probe",
join(root, "clocks.json"),
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
status: "complete",
environment: { state: "served" },
summary: { probes: 2, steps: 3, equal: 2 },
probes: [
{
label: "order.json",
results: [
{
ordinal: 1,
method: "DELETE",
live_status: 204,
env_status: 204,
equal: true,
},
{
ordinal: 0,
method: "GET",
live_status: 404,
env_status: 404,
equal: true,
},
],
},
{
label: "clocks.json",
results: [
{
categories: ["clock-format"],
differences: [
{
category: "clock-format",
pointer: "/created_at",
live: '"0001-01-01 00:00:00.000Z"',
},
],
},
],
},
],
});
for (const [path, text] of originals)
expect(await readFile(path, "utf8")).toBe(text);
},
);
cliTest(
"compares the served environment with a probe of the re-seeded application and prints the report",
async ({ cli }) => {