fix(environment): judge writes by the full served response; model only 2xx writes

The write gate confirmed effects the served environment would not show:
- a list check let a member the state no longer holds pass, so a
  filtered list still showing a deleted entity confirmed the delete; it
  now compares the whole list serving would return;
- item and list checks compared only the entity, so a wrapper member
  that changed after the write (a revision, say) went unnoticed while
  serving kept the base recording's; reads are now compared as the full
  response rendered into the item view's or the line's base envelope;
- a later update's constant rule kept no value, so serving stored the
  first observation's; each constant rule now carries the value the
  call that showed it recorded, and the gate and serving read it alike;
- a redirected or unanswered write could become the example or a
  modelled effect; only a 2xx write is modelled, others taint as
  unmodelled writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-20 13:09:16 -07:00
1 parent 82556cd550
commit 138ea4b198
11 files changed
+321 -92

No files matched your search

+8 -4
View File
@@ -617,16 +617,20 @@ minutes of the request in its recorded format, a `sequence` one past the
largest value of its collection or type, a `unique` UUID, or a `template` around
a generated value; other fields keep the observed value. An update's members
echo when stored as sent; a member stored differently is accepted only with its
recorded value (`body.fixed`), and a field it changed without sending must be a
recorded value (`body.fixed`) and stores the value the call that showed it
recorded (the `value` of its `constant` rule), and a field it changed without sending must be a
clock, an increment or a slug, or the plan is `unexplained-change`. Every
member the first call sent is required, except by `PATCH` and `POST` updates.
The view then replays the timeline on a copy of the initial state. Modelled
writes apply their effect; any other write leaves the entity below whose item
writes apply their effect; only a `2xx` write is modelled or an example, and
any other write (a redirect, an unanswered or unmodelled one) leaves the entity below whose item
path it wrote unknown until a read shows it again (a whole collection when the
path names no held entity), and taints what it wrote at or above. A read the
served environment would answer from the state is compared with its recording
as the read plans are, a 400, 404 or 410 (or a `null` body) agreeing with an
served environment would answer from the state is compared, as the whole
response it would serve (the entity or list rendered into the envelope of the
item view's or the line's base recording, with the members the state still
holds), with its recording as the read plans are, a 400, 404 or 410 (or a `null` body) agreeing with an
entity the copy does not hold. A match confirms the writes that changed its
entity or collection since it was last read; a mismatch contradicts them, or,
when an unmodelled write came after them, is checked only on the members they
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import {
timedCaptureEvidence,
type TimedCaptureRow,
} from "../../../tests/fixtures/analysisView.js";
import {
BOOK_INIT_ROWS,
BOOK_NOT_FOUND,
FIRST_BOOK,
SECOND_BOOK,
THIRD_BOOK,
bookClock,
environmentStateViewOf,
renamedBook,
type Book,
} from "../../../tests/fixtures/environmentStateWrites.js";
import { ok } from "../result.js";
import { updatedFields } from "./environmentStateWriteApply.js";
/** The plan the gate gives `tool` over a capture of `rows`. */
const gated = (rows: readonly TimedCaptureRow[], tool: string) => {
const { writes } = environmentStateViewOf(timedCaptureEvidence(rows));
const plan = writes.find((candidate) => candidate.tool === tool);
if (plan === undefined) throw new Error(`The gate planned no ${tool}`);
return plan;
};
const RENAMED = renamedBook(FIRST_BOOK, "Renamed Book", 3);
describe("reads compared as the served environment answers them", () => {
/** Item reads in a `{data, revision}` envelope, a rename, then a read recorded at `revision`. */
const renamedInEnvelope = (revision: string): TimedCaptureRow[] => [
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books/1", { data: FIRST_BOOK, revision: "r1" }],
["GET", "/api/books/2", { data: SECOND_BOOK, revision: "r1" }],
["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }],
["GET", "/api/books/1", { data: RENAMED, revision }],
];
it("contradicts a write when an item read's envelope differs from the one served", () => {
expect(gated(renamedInEnvelope("r2"), "patch_books_by_id")).toMatchObject({
served: false,
reason: "contradicted",
contradicted_by: 4,
});
expect(gated(renamedInEnvelope("r1"), "patch_books_by_id")).toMatchObject({
served: true,
checked_record_ordinals: [4],
});
});
/** A list in a `{items, revision}` envelope, a create, then the list recorded at `revision`. */
const createdInEnvelope = (revision: string): TimedCaptureRow[] => [
["GET", "/api/books", { items: [FIRST_BOOK, SECOND_BOOK], revision: "r1" }],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
[
"GET",
"/api/books",
{ items: [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK], revision },
],
];
it("contradicts a write when a list's envelope differs from its served line", () => {
expect(gated(createdInEnvelope("r2"), "post_books")).toMatchObject({
served: false,
reason: "contradicted",
contradicted_by: 4,
});
expect(gated(createdInEnvelope("r1"), "post_books")).toMatchObject({
served: true,
checked_record_ordinals: [4],
});
});
/** A filtered list of both books, a delete of the second, then the filtered list recorded with `listed`. */
const deletedFromFiltered = (listed: readonly Book[]): TimedCaptureRow[] => [
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["GET", "/api/books?shelf=a", [FIRST_BOOK, SECOND_BOOK]],
["DELETE", "/api/books/2", "", 204],
["GET", "/api/books?shelf=a", [...listed]],
];
it("contradicts a delete that a filtered list still shows, and confirms one it drops", () => {
expect(
gated(
deletedFromFiltered([FIRST_BOOK, SECOND_BOOK]),
"delete_books_by_id",
),
).toMatchObject({
served: false,
reason: "contradicted",
contradicted_by: 4,
});
expect(
gated(deletedFromFiltered([FIRST_BOOK]), "delete_books_by_id"),
).toMatchObject({ served: true, checked_record_ordinals: [4] });
});
});
describe("update constants", () => {
const paper = (entry: Book, format = "PAPER"): Book => ({ ...entry, format });
const RENAMED_PAPER = paper(RENAMED);
const HARDCOVER = paper(
{ ...SECOND_BOOK, updated_at: bookClock(5) },
"HARDCOVER",
);
/** A rename that keeps the format, then a format change the application stores in capitals. */
const ROWS: TimedCaptureRow[] = [
["GET", "/api/books", [paper(FIRST_BOOK), paper(SECOND_BOOK)]],
["GET", "/api/books/1", paper(FIRST_BOOK)],
["GET", "/api/books/2", paper(SECOND_BOOK)],
["PATCH", "/api/books/1", RENAMED_PAPER, 200, { name: "Renamed Book" }],
["GET", "/api/books/1", RENAMED_PAPER],
["PATCH", "/api/books/2", HARDCOVER, 200, { format: "hardcover" }],
["GET", "/api/books/2", HARDCOVER],
];
it("stores the value the call that showed a constant recorded, as serving applies the plan", () => {
const plan = gated(ROWS, "patch_books_by_id");
expect(plan).toMatchObject({
served: true,
observation: { ordinal: 3, pointer: "" },
body: { fixed: [{ member: "format", sent: "hardcover" }] },
checked_record_ordinals: [4, 6],
});
// Serving reads the plan's observation, the first update's answer, which still shows PAPER.
const served = updatedFields(
plan,
{ before: paper(SECOND_BOOK), observed: RENAMED_PAPER },
{
path: "/api/books/2",
pathValues: new Map([["id", "2"]]),
body: { format: "hardcover" },
id: "2",
},
() => bookClock(9),
);
expect(served).toEqual(
ok(paper({ ...SECOND_BOOK, updated_at: bookClock(9) }, "HARDCOVER")),
);
});
});
describe("write examples", () => {
it("takes no modelled effect or exemplar from a redirected write", () => {
const rows: TimedCaptureRow[] = [
...BOOK_INIT_ROWS,
["DELETE", "/api/books/2", "", 302],
["GET", "/api/books/2", SECOND_BOOK],
["DELETE", "/api/books/2", "", 204],
["GET", "/api/books/2", BOOK_NOT_FOUND, 404],
];
expect(gated(rows, "delete_books_by_id")).toMatchObject({
served: true,
exemplar_ordinal: 5,
checked_record_ordinals: [6],
contradicted_by: null,
});
});
});
@@ -18,8 +18,11 @@ import {
checkDocumentRead,
checkItemRead,
checkListRead,
collectionTarget,
entityTarget,
isAbsence,
succeeded,
withBodies,
writtenFieldChecks,
SimulatedState,
type CheckOutcome,
@@ -78,7 +81,8 @@ export class EnvironmentStateWriteSimulation {
readonly #drafts = new Map<string, WriteDraft>();
readonly #toolOf = new Map<number, StateToolFacts>();
readonly #types = new Map<string, StateType>();
readonly #checkedLines = new Map<string, ReadonlySet<string>>();
/** Each list or document view's checked request lines, with the recording a call of the line is answered on. */
readonly #checkedLines = new Map<string, ReadonlyMap<string, number>>();
readonly #observations = new Set<number>();
readonly #taint: WriteTaint;
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
@@ -111,10 +115,12 @@ export class EnvironmentStateWriteSimulation {
if (view.shape !== "recorded" && view.shape !== "item")
this.#checkedLines.set(
view.tool,
new Set(
new Map(
view.checked_record_ordinals.flatMap((ordinal) => {
const record = byOrdinal.get(ordinal);
return record === undefined ? [] : [requestLine(record)];
return record === undefined
? []
: [[requestLine(record), ordinal] as const];
}),
),
);
@@ -158,7 +164,10 @@ export class EnvironmentStateWriteSimulation {
#step(record: StateRecordFacts, index: number): void {
const tool = this.#toolOf.get(record.ordinal);
if (isStateWrite(record)) this.#write(record, index, tool);
// A redirected or unanswered write may have changed state, but only a 2xx one shows its effect.
if (isStateWrite(record) && !succeeded(record))
this.#unmodelled(record.path, index);
else if (isStateWrite(record)) this.#write(record, index, tool);
else if (tool?.method === "GET" && !this.#observations.has(record.ordinal))
this.#read(record, index, tool);
}
@@ -303,32 +312,37 @@ export class EnvironmentStateWriteSimulation {
#check(view: StateView, record: StateRecordFacts): CheckOutcome | undefined {
if (view.shape === "recorded") return undefined;
if (
view.shape !== "item" &&
!this.#checkedLines.get(view.tool)?.has(requestLine(record))
)
return undefined;
if (
(view.shape === "item" || view.shape === "list") &&
this.#taint.under(collectionPathOf(record, view.shape === "item"))
)
return undefined;
if (view.shape === "list") {
const collection = collectionPathOf(record, false);
const line = this.#checkedLines.get(view.tool)?.get(requestLine(record));
if (view.shape !== "item" && line === undefined) return undefined;
if (view.shape === "document")
return this.#input.io.withBody(record.ordinal, (body) =>
checkDocumentRead(this.#state, view.tool, record, body),
);
const item = view.shape === "item";
const collection = collectionPathOf(record, item);
if (this.#taint.under(collection)) return undefined;
if (!item) {
const ids = this.#state
.members(view.type, collection)
.map((entity) => entity.id);
if (this.#taint.blocksList(view.type, collection, ids)) return undefined;
// A recorded-membership list covers no collection target, so a write at the collection is checked here.
if (
this.#taint.tainted(collectionTarget(view.type, collection)) ||
this.#taint.blocksList(view.type, collection, ids)
)
return undefined;
}
return this.#input.io.withBody(record.ordinal, (body) => {
if (view.shape === "document")
return checkDocumentRead(this.#state, view.tool, record, body);
// The served answer keeps the envelope of the item view's or the line's base recording.
const base = item ? view.envelope_ordinal : line!;
const ordinals = { base, read: record.ordinal };
return withBodies(this.#input.io.withBody, ordinals, (served, body) => {
const check = {
body,
base: served,
idField: this.#types.get(view.type)?.id_field ?? "",
volatile: this.#input.volatileOf(view.type),
};
return view.shape === "item"
return item
? checkItemRead(this.#state, view, record, check)
: checkListRead(this.#state, view, record, check);
});
@@ -120,7 +120,11 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [
z.strictObject({ ...ruled, rule: z.literal("slug"), from: z.string() }),
z.strictObject({ ...ruled, rule: z.literal("template"), from: z.string() }),
z.strictObject({ ...ruled, rule: z.literal("increment") }),
z.strictObject({ ...ruled, rule: z.literal("constant") }),
z.strictObject({
...ruled,
rule: z.literal("constant"),
value: jsonValueSchema,
}),
]);
const insertRuleSchema = z.union([
z.strictObject({ rule: z.enum(["first", "last"]) }),
@@ -242,7 +242,7 @@ const UPDATE_PLAN: Plan = {
rules: [
{ field: "title", rule: "echo" },
{ field: "notes", rule: "echo" },
{ field: "status", rule: "constant" },
{ field: "status", rule: "constant", value: "closed" },
{ field: "slug", rule: "slug", from: "title" },
{ field: "version", rule: "increment" },
{ field: "revision", rule: "increment" },
@@ -191,7 +191,7 @@ export const createdFields = (
return ok(written);
};
/** The fields of an updated entity: sent echoed members, constants from the recorded update, generated and derived changes. */
/** The fields of an updated entity: sent echoed members, the constants the plan recorded, generated and derived changes. */
export const updatedFields = (
plan: Pick<StateWritePlan, "rules" | "body">,
entity: { readonly before: JsonObject; readonly observed: JsonObject },
@@ -218,7 +218,7 @@ export const updatedFields = (
if (rule.rule === "echo" || rule.rule === "constant") {
if (!Object.hasOwn(sent, rule.field)) continue;
written[rule.field] =
rule.rule === "echo" ? sent[rule.field]! : observed[rule.field]!;
rule.rule === "echo" ? sent[rule.field]! : rule.value;
} else if (rule.rule === "increment") {
const was = integerOf(before[rule.field]);
if (was !== undefined)
@@ -8,6 +8,7 @@ import {
listItems,
renderStateEntity,
renderStateList,
replaceAt,
same,
scalarText,
type JsonObject,
@@ -95,26 +96,44 @@ export class SimulatedState {
export const isAbsence = (status: number | null): boolean =>
status === 400 || status === 404 || status === 410;
const succeeded = (record: StateRecordFacts): boolean =>
/** A 2xx record: the only status whose response shows what a request did. */
export const succeeded = (record: StateRecordFacts): boolean =>
record.status !== null && record.status >= 200 && record.status < 300;
/** Hand a base recording's body and a read's body to `use` together, parsing a recording once when both are the same. */
export const withBodies = <T>(
withBody: <U>(ordinal: number, use: (body: JsonValue | undefined) => U) => U,
ordinals: { readonly base: number; readonly read: number },
use: (base: JsonValue | undefined, body: JsonValue | undefined) => T,
): T => {
const { base, read } = ordinals;
return base === read
? withBody(read, (body) => use(body, body))
: withBody(base, (served) => withBody(read, (body) => use(served, body)));
};
/** A check's outcome and the targets it covered. */
export interface CheckOutcome {
readonly pass: boolean;
readonly targets: readonly string[];
/** Entities the recording lists that the state does not hold, by target. */
/** Entities the recording, or the served base of a recorded-membership list, lists that the state does not hold, by target. */
readonly unheld: readonly string[];
}
/** An item read: the recorded entity object must render from the held entity; a recorded 404 or 410 agrees with none held. */
/** What a check compares: the read's recorded body and the served base recording it would answer on. */
interface ReadBodies {
readonly body: JsonValue | undefined;
/** The recording whose envelope the served answer keeps: an item view's envelope, a list line's latest init recording. */
readonly base: JsonValue | undefined;
readonly volatile: ReadonlySet<string>;
}
/** An item read: the answer the held entity renders into the served envelope must equal the recording; a recorded 404 or 410 agrees with none held. */
export const checkItemRead = (
state: SimulatedState,
view: StateItemView,
record: StateRecordFacts,
check: {
readonly body: JsonValue | undefined;
readonly volatile: ReadonlySet<string>;
},
check: ReadBodies,
): CheckOutcome | undefined => {
const collection = collectionPathOf(record, true);
const id = lastSegmentValue(record.path);
@@ -122,103 +141,115 @@ export const checkItemRead = (
const targets = [entityTarget(view.type, collection, id)];
if (isAbsence(record.status) || (succeeded(record) && check.body === null))
return { pass: entity === undefined, targets, unheld: [] };
if (record.status === null || record.status < 200 || record.status >= 300)
return undefined;
if (!succeeded(record) || check.base === undefined) return undefined;
const object =
check.body === undefined ? undefined : at(check.body, view.pointer);
if (!isObject(object)) return undefined;
if (entity === undefined) return { pass: false, targets, unheld: targets };
const shown = renderStateEntity(entity, view, check.volatile, object);
return {
pass: same(renderStateEntity(entity, view, check.volatile, object), object),
pass: same(replaceAt(check.base, view.pointer, shown), check.body),
targets,
unheld: [],
};
};
/** A list read's bodies with the listed items of the read and of its served base. */
interface ListBodies {
readonly body: JsonValue;
readonly base: JsonValue;
readonly items: readonly JsonObject[];
readonly baseItems: readonly JsonObject[];
readonly idField: string;
readonly volatile: ReadonlySet<string>;
}
/** The served list rendered from the base with `members`, compared with the read; volatile fields take the read's values. */
const listCheck = (
view: StateListView,
check: ListBodies,
members: readonly StateEntity[],
): boolean => {
const observed = new Map(
check.items.map((item) => [scalarText(item[check.idField]) ?? "", item]),
);
const rendered = members.map((entity) =>
renderStateEntity(entity, view, check.volatile, observed.get(entity.id)),
);
return same(renderStateList(view, check.base, rendered), check.body);
};
/** A list read of every entity of a collection: membership, order, fields and counters must render from the state. */
const checkAllList = (
state: SimulatedState,
view: StateListView,
record: StateRecordFacts,
check: {
readonly body: JsonValue;
readonly items: readonly JsonObject[];
readonly idField: string;
readonly volatile: ReadonlySet<string>;
},
check: ListBodies,
): CheckOutcome => {
const collection = collectionPathOf(record, false);
const members = state.members(view.type, collection);
const observed = new Map(
check.items.map((item) => [scalarText(item[check.idField]) ?? "", item]),
);
const rendered = members.map((entity) =>
renderStateEntity(entity, view, check.volatile, observed.get(entity.id)),
);
const held = new Set(members.map((entity) => entity.id));
return {
pass: same(renderStateList(view, check.body, rendered), check.body),
pass: listCheck(view, check, members),
targets: [
collectionTarget(view.type, collection),
...members.map((entity) =>
entityTarget(view.type, collection, entity.id),
),
],
unheld: [...observed.keys()]
unheld: check.items
.map((item) => scalarText(item[check.idField]) ?? "")
.filter((id) => !held.has(id))
.map((id) => entityTarget(view.type, collection, id)),
};
};
/** A list read of recorded members: every listed entity the state holds must render; others are not compared. */
/** A list read of recorded members: the served list holds the base's members the state still holds, so a member it dropped or never listed fails. */
const checkRecordedList = (
state: SimulatedState,
view: StateListView,
record: StateRecordFacts,
check: {
readonly items: readonly JsonObject[];
readonly idField: string;
readonly volatile: ReadonlySet<string>;
},
check: ListBodies,
): CheckOutcome => {
const collection = collectionPathOf(record, false);
const idOf = (item: JsonObject) => scalarText(item[check.idField]) ?? "";
const members = check.baseItems.flatMap((item) => {
const entity = state.entity(view.type, collection, idOf(item));
return entity === undefined ? [] : [entity];
});
const targets: string[] = [];
const unheld: string[] = [];
let pass = true;
for (const item of check.items) {
const id = scalarText(item[check.idField]) ?? "";
const target = entityTarget(view.type, collection, id);
const entity = state.entity(view.type, collection, id);
if (entity === undefined) {
unheld.push(target);
continue;
}
targets.push(target);
if (!same(renderStateEntity(entity, view, check.volatile, item), item))
pass = false;
}
return { pass, targets, unheld };
for (const id of new Set([...check.baseItems, ...check.items].map(idOf)))
(state.entity(view.type, collection, id) === undefined
? unheld
: targets
).push(entityTarget(view.type, collection, id));
return { pass: listCheck(view, check, members), targets, unheld };
};
/** A list read on a checked line, compared as the served environment answers it. */
/** A list read on a checked line, compared with the answer the served environment renders on that line's base recording. */
export const checkListRead = (
state: SimulatedState,
view: StateListView,
record: StateRecordFacts,
check: {
readonly body: JsonValue | undefined;
readonly idField: string;
readonly volatile: ReadonlySet<string>;
},
check: ReadBodies & { readonly idField: string },
): CheckOutcome | undefined => {
if (record.status === null || record.status < 200 || record.status >= 300)
if (!succeeded(record)) return undefined;
const { body, base } = check;
const items = body === undefined ? undefined : listItems(body, view.pointer);
const baseItems =
base === undefined ? undefined : listItems(base, view.pointer);
if (
body === undefined ||
base === undefined ||
items === undefined ||
baseItems === undefined
)
return undefined;
const items =
check.body === undefined ? undefined : listItems(check.body, view.pointer);
if (check.body === undefined || items === undefined) return undefined;
const bodies = { ...check, body, base, items, baseItems };
return view.membership === "all"
? checkAllList(state, view, record, { ...check, body: check.body, items })
: checkRecordedList(state, view, record, { ...check, items });
? checkAllList(state, view, record, bodies)
: checkRecordedList(state, view, record, bodies);
};
/** A document read on its checked line must equal the held document. */
@@ -228,8 +259,7 @@ export const checkDocumentRead = (
record: StateRecordFacts,
body: JsonValue | undefined,
): CheckOutcome | undefined => {
if (record.status === null || record.status < 200 || record.status >= 300)
return undefined;
if (!succeeded(record)) return undefined;
const value = state.document(tool);
if (body === undefined || value === undefined) return undefined;
return {
@@ -322,9 +322,10 @@ export const applyUpdate = (
return recorded();
}
buildUpdate(context, draft, step, { after, explained, idField });
// Serving reads the plan's observation, so the gate does too; generated values are this call's.
const written = updatedFields(
draft.plan,
{ before: entity.fields, observed: after.object },
{ before: entity.fields, observed: draft.observed! },
{ ...call, id },
(rule) => after.object[rule.field] ?? null,
);
@@ -266,7 +266,7 @@ it("explains an update's sent members as echoes, constants or the identity", ()
expect(result).toEqual({
rules: [
{ field: "title", rule: "echo" },
{ field: "status", rule: "constant" },
{ field: "status", rule: "constant", value: "closed" },
],
body: {
accepted: ["title"],
@@ -335,7 +335,9 @@ it("leaves a slug of a transformed member, an untimed clock and a jump unexplain
{ startMs: undefined },
),
);
expect(result.rules).toEqual([{ field: "title", rule: "constant" }]);
expect(result.rules).toEqual([
{ field: "title", rule: "constant", value: "new title" },
]);
expect(result.unexplained).toEqual(["slug", "at", "n"]);
});
@@ -287,7 +287,12 @@ export const explainUpdate = (
if (same(evidence.after[member], sent)) {
echoed.add(member);
rules.push({ field: member, rule: "echo" });
} else rules.push({ field: member, rule: "constant" });
} else
rules.push({
field: member,
rule: "constant",
value: evidence.after[member]!,
});
}
const unexplained: string[] = [];
for (const field of Object.keys(evidence.after)) {
@@ -50,7 +50,12 @@ export type FieldRule =
| { readonly field: string; readonly rule: "slug"; readonly from: string }
| { readonly field: string; readonly rule: "template"; readonly from: string }
| { readonly field: string; readonly rule: "increment" }
| { readonly field: string; readonly rule: "constant" };
/** An update stores `value` whenever the call sends the member, as the call that showed it did. */
| {
readonly field: string;
readonly rule: "constant";
readonly value: JsonValue;
};
/** Where a create places its entity in its collection's order. */
export type InsertRule =