diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 59106bb20a..cbd496594f 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -771,8 +771,28 @@ recording replayed in its place and never from a recording whose body the probe two collections can reuse a live id, and an equal pair stays in that collection. A live id in a later request's path, query or body is translated before the environment answers, a path parameter by the collection the live path names (a nested collection keeps its parent values, each decoded once, and a literal route segment stays), except a query value that pages a list, such as `limit` or `page`. Each call is checked against the served tool's input schema first, as the MCP server checks it. After mapping, two clock values compare by format only (ISO-8601 strings, and epoch numbers under -`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. +`at`, `time`, `date`, `timestamp`, `created`, `updated` or `modified` field names, including snake-case and camel-case forms). +Numeric arrays retain that containing field's clock context through nested +arrays; an object's own fields establish their own context. Numeric values at +the body root, in root arrays, or under measurement fields remain literal. + +Generated strings normalize only where this confirmed, served write's entity +response rules identify generation. A `unique` rule or UUID identity rule must +still compare two UUIDs; a `renewed` rule applies only at its recorded pointers, +with `*` matching actual array items. Token/sequence strings retain the existing +format check: equal length of at least eight, the same character classes, and +a digit. Numeric sequences continue to compare literally unless an ID mapping +applies. String shape and names such as `token` or `sku` alone do not establish +generation. Read and replay responses without these facts stay literal except +for supported clocks and learned IDs. A missing write target replays an absence +response and does not inherit the successful entity response's generation rules. +Admitted generated values must also preserve aliases and distinct identities +within that response. UUID identity uses the generator's case-insensitive +matching; token and sequence strings retain their literal identity. This +correspondence ends with the answer and is not inferred across calls. +The report counts each kind of normalised +value; the generation rules remain model-derived facts, rather than new +observations of the application. ISO clock offsets are accepted only within RFC 3339's `00`–`23` hour and `00`–`59` minute ranges. 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 diff --git a/src/application/environment/environmentCompare.ts b/src/application/environment/environmentCompare.ts index 2c0e470599..b75b82a021 100644 --- a/src/application/environment/environmentCompare.ts +++ b/src/application/environment/environmentCompare.ts @@ -1,4 +1,5 @@ import { decodedSegment } from "../../domain/analysisView/environmentStateRules.js"; +import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js"; import { fromJsonSchema } from "@modelcontextprotocol/server"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { JsonValue } from "../../domain/jsonValue.js"; @@ -139,22 +140,37 @@ const entityOf = ( run: ProbeRun, tool: EnvironmentManifestTool, segments: readonly string[], + answer: ReplayResult, ): { readonly scope: string; readonly at: string; readonly identity: boolean; + readonly rules: readonly FieldRule[]; } => { const view = run.store?.state.views.get(tool.name); const plan = run.store?.state.writes.get(tool.name); const response = plan?.response; const scope = collectionScope(tool, segments); if (view?.shape === "item" || view?.shape === "list") - return { scope, at: view.pointer, identity: false }; + return { scope, at: view.pointer, identity: false, rules: [] }; const identity = response?.shape === "identity"; const at = identity ? (response.pointer ?? "") : (response?.pointer ?? plan?.observation?.pointer ?? ""); - return { scope, at, identity }; + const provenance = (answer as Partial).state; + const renderedWrite = + provenance?.source === "state" && + (provenance.view === "create" || + provenance.view === "update" || + provenance.view === "document-write" || + provenance.view === "delete"); + // A missing target replays a recorded absence with state provenance too; + // only an applied write can render the plan's entity response. + const rules = + plan?.served === true && response?.shape === "entity" && renderedWrite + ? plan.rules + : []; + return { scope, at, identity, rules }; }; const fromState = (answer: ReplayResult): boolean => @@ -350,7 +366,7 @@ const compareStep = ( status: answered.value.status, body: envBody(run, answered.value), }; - const entity = entityOf(run, call.tool, liveSegments); + const entity = entityOf(run, call.tool, liveSegments, answered.value); // Another call's recording holds other entities, whose ids would map wrongly and, the first pairing winning, for good. if (answersCall(run, call, args, answered.value)) learnIds(identityValue(live.body), identityValue(env.body), run.map, { diff --git a/src/application/environment/environmentCompareDiff.test.ts b/src/application/environment/environmentCompareDiff.test.ts index 0d5b160adc..e039e8dcb4 100644 --- a/src/application/environment/environmentCompareDiff.test.ts +++ b/src/application/environment/environmentCompareDiff.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../../domain/jsonValue.js"; +import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js"; import { compareAnswers } from "./environmentCompareDiff.js"; import { IdMap, learnIds } from "./environmentCompareIds.js"; @@ -9,11 +10,13 @@ const compare = ( env: unknown, map = new IdMap(), status: [number, number] = [200, 200], + rules: readonly FieldRule[] = [], ) => compareAnswers( { status: status[0], body: { kind: "json", value: live as JsonValue } }, { status: status[1], body: { kind: "json", value: env as JsonValue } }, map, + { rules }, ); describe("early-year clock formats", () => { @@ -32,7 +35,7 @@ describe("early-year clock formats", () => { }); describe("compareAnswers", () => { - it("agrees on mapped ids, clocks of one format and generated values, and counts each", () => { + it("agrees on mapped ids, clocks and confirmed generated values, and counts each", () => { const map = new IdMap(); map.learn(101, 1); const result = compare( @@ -49,6 +52,11 @@ describe("compareAnswers", () => { uuid: "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d", }, map, + [200, 200], + [ + { field: "token", rule: "identity", generator: "token" }, + { field: "uuid", rule: "unique" }, + ], ); expect(result.differences).toEqual([]); expect(result.normalised).toEqual({ @@ -105,7 +113,9 @@ describe("compareAnswers", () => { ), ).toEqual(["value"]); }); +}); +describe("mapped fields and literal collections", () => { it("compares an object keyed by mapped ids under the environment's keys", () => { const map = new IdMap(); map.learn("liveTable", "envTable"); @@ -167,7 +177,7 @@ describe("compareAnswers", () => { ).toEqual([]); }); - it("reports a reordered set of scalars as order alone and reads epoch array items as clocks", () => { + it("reports a reordered set of scalars as order alone and keeps nested numeric measurements literal", () => { expect( compare( { access: ["a", "b", "c"] }, @@ -177,7 +187,223 @@ describe("compareAnswers", () => { expect( compare({ stats: [[1792450800, 3]] }, { stats: [[1792285200, 3]] }) .differences, - ).toEqual([]); + ).toEqual([ + { + category: "value", + pointer: "/stats/0/0", + live: "1792450800", + env: "1792285200", + }, + ]); + }); +}); + +describe("normalization context", () => { + it.each([ + { live: 1800000000, env: 1700000000, pointer: "" }, + { live: [1800000000], env: [1700000000], pointer: "/0" }, + { + live: { samples: [1800000000] }, + env: { samples: [1700000000] }, + pointer: "/samples/0", + }, + { + live: { updated_at: [{ samples: 1800000000 }] }, + env: { updated_at: [{ samples: 1700000000 }] }, + pointer: "/updated_at/0/samples", + }, + ])( + "keeps epoch-shaped numbers literal at $pointer without a time field", + ({ live, env, pointer }) => { + const result = compare(live, env); + expect(result.differences).toEqual([ + { category: "value", pointer, live: "1800000000", env: "1700000000" }, + ]); + expect(result.normalised.clocks).toBe(0); + }, + ); + + it("preserves the time field through nested numeric clock arrays", () => { + const result = compare( + { updated_at: [[1700000000]] }, + { updated_at: [[1800000000]] }, + ); + expect(result.differences).toEqual([]); + expect(result.normalised.clocks).toBe(1); + }); + + it.each([ + ["product1", "product2"], + ["product1", "productX"], + ["3f2a9c8d7e", "9b8e7d6c5a"], + [ + "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b", + "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d", + ], + ])( + "keeps literal business strings %s versus %s without generation facts", + (live, env) => { + const result = compare( + { sku: live, items: [live] }, + { sku: env, items: [env] }, + ); + expect( + result.differences.map(({ category, pointer }) => [category, pointer]), + ).toEqual([ + ["value", "/sku"], + ["value", "/items/0"], + ]); + expect(result.normalised.generated).toBe(0); + }, + ); + + it("scopes confirmed renewal rules to their response entity and exact array pointers", () => { + const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b"; + const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d"; + const answer = (token: string) => ({ + status: 200, + body: { + kind: "json" as const, + value: { + data: { values: [{ token, sku: token }] }, + values: [{ token }], + }, + }, + }); + const result = compareAnswers(answer(first), answer(second), new IdMap(), { + at: "/data", + rules: [{ field: "values", rule: "renewed", pointers: ["/*/token"] }], + }); + expect(result.normalised.generated).toBe(1); + expect( + result.differences.map(({ category, pointer }) => [category, pointer]), + ).toEqual([ + ["value", "/data/values/0/sku"], + ["value", "/values/0/token"], + ]); + }); + + it("does not extend generated-string normalization to numeric sequences", () => { + const result = compare( + { code: 17 }, + { code: 18 }, + new IdMap(), + [200, 200], + [{ field: "code", rule: "sequence", scope: "type" }], + ); + expect(result.differences).toEqual([ + { category: "value", pointer: "/code", live: "17", env: "18" }, + ]); + expect(result.normalised.generated).toBe(0); + }); +}); + +describe("generated response identities", () => { + const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b"; + const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d"; + const fresh = "123e4567-e89b-42d3-a456-426614174000"; + const other = "223e4567-e89b-42d3-a456-426614174000"; + const rules: readonly FieldRule[] = [ + { field: "left", rule: "renewed", pointers: ["/*"] }, + { field: "right", rule: "renewed", pointers: ["/*"] }, + ]; + const answer = (left: string, right: string) => ({ + status: 200, + body: { kind: "json" as const, value: { left: [left], right: [right] } }, + }); + + it.each([ + { + name: "consistent fresh renaming", + liveLeft: fresh, + liveRight: fresh, + envLeft: first, + envRight: first, + differs: false, + generated: 2, + }, + { + name: "case-equivalent UUID aliases", + liveLeft: fresh.toUpperCase(), + liveRight: fresh, + envLeft: first, + envRight: first.toUpperCase(), + differs: false, + generated: 2, + }, + { + name: "split alias", + liveLeft: fresh, + liveRight: other, + envLeft: first, + envRight: first, + differs: true, + generated: 1, + }, + { + name: "merged identities", + liveLeft: fresh, + liveRight: fresh, + envLeft: first, + envRight: second, + differs: true, + generated: 1, + }, + { + name: "split after an unchanged array", + liveLeft: first, + liveRight: fresh, + envLeft: first, + envRight: first, + differs: true, + generated: 0, + }, + { + name: "merge after an unchanged array", + liveLeft: first, + liveRight: first, + envLeft: first, + envRight: second, + differs: true, + generated: 0, + }, + ])( + "preserves $name within one confirmed renewal response", + ({ liveLeft, liveRight, envLeft, envRight, differs, generated }) => { + const result = compareAnswers( + answer(liveLeft, liveRight), + answer(envLeft, envRight), + new IdMap(), + { rules }, + ); + expect(result.normalised.generated).toBe(generated); + expect(result.differences).toEqual( + differs + ? [ + { + category: "value", + pointer: "/right/0", + live: JSON.stringify(liveRight), + env: JSON.stringify(envRight), + }, + ] + : [], + ); + }, + ); + + it("retains order-only comparison for reordered generated scalar sets", () => { + const result = compare( + { left: [first, second] }, + { left: [second, first] }, + new IdMap(), + [200, 200], + rules, + ); + expect(result.differences).toEqual([ + { category: "order", pointer: "/left" }, + ]); + expect(result.normalised.generated).toBe(0); }); }); @@ -316,6 +542,32 @@ describe("identity responses two collections reuse", () => { }); describe("wrapped answers", () => { + it("keeps collection identity context separate from a wrapped array's clock field context", () => { + const map = new IdMap(); + map.learn(101, 1, "/api/books"); + map.learn(101, 2, "/api/authors"); + const result = compareAnswers( + { + status: 200, + body: { + kind: "json", + value: { data: [{ id: 101, updated_at: [1700000000] }] }, + }, + }, + { + status: 200, + body: { + kind: "json", + value: { data: [{ id: 2, updated_at: [1800000000] }] }, + }, + }, + map, + { scope: "/api/authors", at: "/data" }, + ); + expect(result.differences).toEqual([]); + expect(result.normalised).toMatchObject({ ids: 1, clocks: 1 }); + }); + it("compares the identity of an entity inside an envelope within the call's collection", () => { const map = new IdMap(); map.learn(101, 1, "/api/books"); diff --git a/src/application/environment/environmentCompareDiff.ts b/src/application/environment/environmentCompareDiff.ts index 350363d842..b7ac1dc5f9 100644 --- a/src/application/environment/environmentCompareDiff.ts +++ b/src/application/environment/environmentCompareDiff.ts @@ -1,4 +1,5 @@ import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js"; +import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js"; import { isUuid, readClock, @@ -74,16 +75,15 @@ const formatKey = (format: ClockFormat): string => ? `epoch-${format.unit}` : `iso${format.separator}${String(format.fraction)}${format.zone === "" ? "" : format.zone === "Z" ? "Z" : "offset"}`; -/** The format of a clock value: an ISO-8601 string, or an epoch number under a time-named field or as an array item. */ +/** The format of a clock value: an ISO-8601 string, or an epoch number under a time-named field. */ const clockFormat = ( value: JsonValue, key: string | undefined, ): string | undefined => { if ( typeof value === "number" && - key !== undefined && - !TIME_FIELD.test(key) && - !TIME_FIELD_CAMEL.test(key) + (key === undefined || + (!TIME_FIELD.test(key) && !TIME_FIELD_CAMEL.test(key))) ) return undefined; const clock = readClock(value); @@ -102,16 +102,42 @@ const characterClasses = (text: string): string => .sort() .join(""); -/** Two values each side generated on its own: both UUIDs, or one length of eight or more with the same character classes and a digit. */ -const generatedPair = (live: string, env: string): boolean => - (isUuid(live) && isUuid(env)) || - (live.length === env.length && +/** A confirmed response field's rule, and the pointer within that field (`*` marks actual array traversal). */ +interface Generation { + readonly rule: FieldRule; + readonly pointer: string; +} + +/** Whether a confirmed generation rule applies here and both strings retain its generated format. */ +const generatedPair = ( + live: string, + env: string, + generation: Generation | undefined, +): boolean => { + if (generation === undefined) return false; + const { rule, pointer } = generation; + if (rule.rule === "renewed") + return rule.pointers.includes(pointer) && isUuid(live) && isUuid(env); + if (pointer !== "") return false; + if ( + rule.rule === "unique" || + (rule.rule === "identity" && rule.generator === "uuid") + ) + return isUuid(live) && isUuid(env); + if (rule.rule !== "sequence" && rule.rule !== "identity") return false; + return ( + live.length === env.length && live.length >= 8 && /\d/u.test(live) && /\d/u.test(env) && - characterClasses(live) === characterClasses(env)); + characterClasses(live) === characterClasses(env) + ); +}; class Differ { + readonly #rules: ReadonlyMap; + readonly #generatedToEnv = new Map(); + readonly #generatedToLive = new Map(); readonly differences: Difference[] = []; readonly normalised: Normalised = { ids: 0, @@ -128,13 +154,39 @@ class Differ { readonly scope: string | undefined; readonly at: string; readonly identity: boolean; + /** Parsed rules of the confirmed write whose entity response is being compared. */ + readonly rules: readonly FieldRule[]; }, - ) {} + ) { + this.#rules = new Map(entity.rules.map((rule) => [rule.field, rule])); + } add(difference: Difference): void { this.differences.push(difference); } + /** One answer's generated identities must rename consistently in both directions. */ + #pairGenerated(live: string, env: string, rule: FieldRule): boolean { + const uuid = + rule.rule === "unique" || + rule.rule === "renewed" || + (rule.rule === "identity" && rule.generator === "uuid"); + const identity = (value: string) => + uuid ? `uuid:${value.toLowerCase()}` : `string:${value}`; + const liveKey = identity(live); + const envKey = identity(env); + const knownEnv = this.#generatedToEnv.get(liveKey); + const knownLive = this.#generatedToLive.get(envKey); + if ( + (knownEnv !== undefined && knownEnv !== envKey) || + (knownLive !== undefined && knownLive !== liveKey) + ) + return false; + this.#generatedToEnv.set(liveKey, envKey); + this.#generatedToLive.set(envKey, liveKey); + return true; + } + /** Whether a value compares within the call's collection, as an entity's own identity or a create's bare id does; other ids compare by the probe's mapping. */ #inCollection( pointer: string, @@ -162,10 +214,29 @@ class Differ { live: JsonValue, env: JsonValue, pointer: string, - at: { readonly key?: string; readonly own?: boolean } = {}, + at: { + readonly key?: string; + readonly own?: boolean; + readonly clockKey?: string | undefined; + } = {}, + generation?: Generation, ): void { + const key = at.clockKey ?? at.key; + const liveClock = clockFormat(live, key); + const envClock = clockFormat(env, key); + const generatedMatch = + generation !== undefined && + typeof live === "string" && + typeof env === "string" && + liveClock === undefined && + envClock === undefined && + !this.map.hasLive(live) && + !this.map.hasEnv(env) && + generatedPair(live, env, generation) + ? this.#pairGenerated(live, env, generation.rule) + : undefined; if (live === env) { - if (this.#reassigned(live, pointer, at)) + if (generatedMatch === false || this.#reassigned(live, pointer, at)) this.add({ category: "value", pointer, @@ -174,17 +245,17 @@ class Differ { }); return; } - const { key } = at; + const field = at.key; if ( (typeof live === "string" || typeof live === "number") && (typeof env === "string" || typeof env === "number") ) { const mapsAsId = - (key !== undefined && ID_FIELD.test(key)) || + (field !== undefined && ID_FIELD.test(field)) || this.#inCollection(pointer, at) || (typeof live === "string" && - key !== undefined && - this.map.isStateReference(live, key)); + field !== undefined && + this.map.isStateReference(live, field)); const mapped = mapsAsId ? this.map.env( live, @@ -195,8 +266,6 @@ class Differ { this.normalised.ids += 1; return; } - const liveClock = clockFormat(live, key); - const envClock = clockFormat(env, key); if (liveClock !== undefined && envClock !== undefined) { if (liveClock === envClock) this.normalised.clocks += 1; else @@ -208,13 +277,7 @@ class Differ { }); return; } - if ( - typeof live === "string" && - typeof env === "string" && - !this.map.hasLive(live) && - !this.map.hasEnv(env) && - generatedPair(live, env) - ) { + if (generatedMatch === true) { this.normalised.generated += 1; return; } @@ -227,9 +290,28 @@ class Differ { }); } - array(live: JsonValue[], env: JsonValue[], pointer: string): void { + array( + live: JsonValue[], + env: JsonValue[], + pointer: string, + clockKey: string | undefined, + generation: Generation | undefined, + ): void { const own = pointer === this.entity.at; - if (this.sameItems(live, env, pointer)) return; + if (this.sameItems(live, env, pointer)) { + // Equal/reordered scalar sets still establish unchanged generated pairs; + // otherwise another field could split an alias hidden by this fast path. + if (generation !== undefined) + for (const [index, item] of live.entries()) + this.scalar( + item, + item, + `${pointer}/${String(index)}`, + { own, clockKey }, + { ...generation, pointer: `${generation.pointer}/*` }, + ); + return; + } const pairs = pairElements(live, env); if (live.length !== env.length || pairs.length !== live.length) this.add({ @@ -244,9 +326,18 @@ class Differ { ) this.add({ category: "order", pointer }); for (const [left, right] of pairs) - this.value(live[left]!, env[right]!, `${pointer}/${String(left)}`, { - own, - }); + this.value( + live[left]!, + env[right]!, + `${pointer}/${String(left)}`, + { + own, + clockKey, + }, + generation === undefined + ? undefined + : { ...generation, pointer: `${generation.pointer}/*` }, + ); } /** Arrays of scalars holding the same items in another order differ only in order, as sets an application returns unordered do. */ @@ -282,6 +373,7 @@ class Differ { env: JsonObject, pointer: string, own = false, + generation?: Generation, ): void { const self = own || pointer === this.entity.at; // An object keyed by ids compares each member under the environment's key; any other compares field by field. @@ -306,11 +398,25 @@ class Differ { }); else { matched.add(other); - this.value(value, env[other]!, child, { - key, - own: self, - keyed: self && keyed, - }); + const rule = self ? this.#rules.get(key) : undefined; + this.value( + value, + env[other]!, + child, + { + key, + own: self, + keyed: self && keyed, + }, + rule !== undefined + ? { rule, pointer: "" } + : generation === undefined + ? undefined + : { + ...generation, + pointer: `${generation.pointer}/${escapePointer(key)}`, + }, + ); } } for (const [key, value] of Object.entries(env)) @@ -331,16 +437,19 @@ class Differ { readonly key?: string; readonly own?: boolean; readonly keyed?: boolean; + readonly clockKey?: string | undefined; } = {}, + generation?: Generation, ): void { if (Array.isArray(live) && Array.isArray(env)) - this.array(live, env, pointer); + this.array(live, env, pointer, at.clockKey ?? at.key, generation); else if (isObject(live) && isObject(env)) this.object( live, env, pointer, at.own === true && (at.key === undefined || at.keyed === true), + generation, ); else if ( Array.isArray(live) || @@ -354,7 +463,7 @@ class Differ { live: shown(live), env: shown(env), }); - else this.scalar(live, env, pointer, at); + else this.scalar(live, env, pointer, at, generation); } } @@ -375,12 +484,14 @@ export const compareAnswers = ( readonly scope?: string; readonly at?: string; readonly identity?: boolean; + readonly rules?: readonly FieldRule[]; } = {}, ): { readonly differences: Difference[]; readonly normalised: Normalised } => { const differ = new Differ(map, { scope: entity.scope, at: entity.at ?? "", identity: entity.identity === true, + rules: entity.rules ?? [], }); if (live.status !== env.status) differ.add({ diff --git a/tests/boundary/cli/environmentCompareCli.test.ts b/tests/boundary/cli/environmentCompareCli.test.ts index 49cfe168bd..e8d2308939 100644 --- a/tests/boundary/cli/environmentCompareCli.test.ts +++ b/tests/boundary/cli/environmentCompareCli.test.ts @@ -7,13 +7,18 @@ import { analysisViewCaptureEvidence, environmentManifestCaptureFixture, timedCaptureEvidence, + type TimedCaptureRow, } from "../../fixtures/analysisView.js"; import { environmentStateServeFixture } from "../../fixtures/environmentServe.js"; import { BOOK_NOT_FOUND, BOOK_ROWS, FIRST_BOOK, + SECOND_BOOK, + THIRD_BOOK, book, + numberedUuid, + renamedBook, } from "../../fixtures/environmentStateWrites.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -98,7 +103,242 @@ const writeExactClockFiles = async ( return originals; }; -describe("rea environment-compare", () => { +/** Capture-confirmed generated writes and literal/clock probes, retained as unchanged Evidence files. */ +const writeValueContextFiles = async ( + root: string, +): Promise> => { + const recorded = { + samples: [1700000000], + nested_samples: [[1700000000]], + scalar_samples: 1700000000, + sku: "product1", + catalog: ["product1"], + serial: numberedUuid(1), + updated_at: [1700000000], + nested: { updated_at: 1700000000 }, + fixed_samples: [1700000000], + }; + const clocks = { + ...recorded, + updated_at: [1800000000], + nested: { updated_at: 1800000000 }, + }; + const changed = { + ...clocks, + samples: [1800000000], + nested_samples: [[1800000000]], + scalar_samples: 1800000000, + sku: "product2", + catalog: ["product2"], + serial: numberedUuid(2), + }; + const first = { ...FIRST_BOOK, ref: numberedUuid(1) }; + const second = { ...SECOND_BOOK, ref: numberedUuid(2) }; + const third = { ...THIRD_BOOK, ref: numberedUuid(3) }; + const renamed = { + ...renamedBook(first, "Renamed Book", 6), + ref: numberedUuid(4), + }; + const missing = { error: "missing", ref: numberedUuid(99) }; + const rows: TimedCaptureRow[] = [ + ["GET", "/api/config", recorded], + ["GET", "/api/books", [first, second]], + ["GET", "/api/books/1", first], + ["GET", "/api/books/2", second], + ["POST", "/api/books", third, 201, { name: "Third Book" }], + ["GET", "/api/books/3", third], + ["PATCH", "/api/books/1", renamed, 200, { name: "Renamed Book" }], + ["GET", "/api/books/1", renamed], + ["GET", "/api/books/99", missing, 404], + ]; + const capture = timedCaptureEvidence(rows); + const fixture = environmentStateServeFixture(capture); + expect(fixture.state.normalized_result).toMatchObject({ + writes: expect.arrayContaining([ + expect.objectContaining({ + tool: "post_books", + served: true, + response: { ordinal: 4, shape: "entity", pointer: "" }, + rules: expect.arrayContaining([{ field: "ref", rule: "unique" }]), + }), + expect.objectContaining({ + tool: "patch_books_by_id", + served: true, + response: { ordinal: 6, shape: "entity", pointer: "" }, + rules: expect.arrayContaining([{ field: "ref", rule: "unique" }]), + }), + ]), + }); + const generated = { + ...book(101, "Third Book", "2026-11-01T12:00:00Z"), + ref: numberedUuid(101), + }; + const originals = new Map(); + for (const [name, value] of [ + ["manifest.json", fixture.manifest], + ["capture.json", capture], + ["changed.json", timedCaptureEvidence([["GET", "/api/config", changed]])], + ["clocks.json", timedCaptureEvidence([["GET", "/api/config", clocks]])], + [ + "generated.json", + timedCaptureEvidence([ + ["POST", "/api/books", generated, 201, { name: "Third Book" }], + [ + "PATCH", + "/api/books/1", + { ...renamed, ref: numberedUuid(102) }, + 200, + { name: "Renamed Book" }, + ], + ]), + ], + [ + "missing.json", + timedCaptureEvidence([ + [ + "PATCH", + "/api/books/99", + { ...missing, ref: numberedUuid(100) }, + 404, + { name: "Ghost" }, + ], + ]), + ], + ] 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 value context", () => { + cliTest( + "keeps business values and numeric measurements literal while normalizing confirmed generation and clocks", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-environment-value-context-", + ); + const originals = await writeValueContextFiles(root); + const result = await cli.run({ + arguments: [ + "environment-compare", + join(root, "manifest.json"), + "--evidence", + join(root, "capture.json"), + "--probe", + join(root, "changed.json"), + "--probe", + join(root, "clocks.json"), + "--probe", + join(root, "generated.json"), + "--probe", + join(root, "missing.json"), + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + status: "complete", + summary: { + probes: 4, + steps: 5, + equal: 3, + agreement: 0.6, + normalised: { generated: 2 }, + }, + probes: [ + { + label: "changed.json", + normalised: { generated: 0, clocks: 2, ids: 0 }, + results: [ + { + equal: false, + categories: ["value"], + differences: [ + { + category: "value", + pointer: "/samples/0", + live: "1800000000", + env: "1700000000", + }, + { + category: "value", + pointer: "/nested_samples/0/0", + live: "1800000000", + env: "1700000000", + }, + { + category: "value", + pointer: "/scalar_samples", + live: "1800000000", + env: "1700000000", + }, + { + category: "value", + pointer: "/sku", + live: '"product2"', + env: '"product1"', + }, + { + category: "value", + pointer: "/catalog/0", + live: '"product2"', + env: '"product1"', + }, + { + category: "value", + pointer: "/serial", + live: JSON.stringify(numberedUuid(2)), + env: JSON.stringify(numberedUuid(1)), + }, + ], + }, + ], + }, + { + label: "clocks.json", + normalised: { clocks: 2, generated: 0 }, + results: [{ equal: true, differences: [] }], + }, + { + label: "generated.json", + normalised: { generated: 2, ids: 1 }, + results: [ + { equal: true, differences: [] }, + { equal: true, differences: [] }, + ], + }, + { + label: "missing.json", + normalised: { generated: 0 }, + results: [ + { + tool: "patch_books_by_id", + live_status: 404, + env_status: 404, + equal: false, + differences: [ + { + category: "value", + pointer: "/ref", + live: JSON.stringify(numberedUuid(100)), + env: JSON.stringify(numberedUuid(99)), + }, + ], + }, + ], + }, + ], + }); + for (const [path, text] of originals) + expect(await readFile(path, "utf8")).toBe(text); + }, + ); +}); + +describe("rea environment-compare replay and chronology", () => { cliTest( "preserves exact probe chronology and first-century clock-format differences", async ({ cli }) => {