chore: merge environment state writes (7cef2fb2) into environment list lines

Brings the fixes for two findings Greptile raised here against the state-writes code: zero page sizes erasing positive bounds, and interference timed by a tool's call on another collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-21 12:23:31 -07:00
commit 8eea9f6d55
10 files changed
+134 -52

No files matched your search

@@ -108,8 +108,9 @@ value the application ignored it is accepted with that value and stores
nothing, and a stored id that changed is `unexplained-change`. A plan whose
calls use a member both as the identity and as a fixed member, or fix it at
different values, is `unexplained-change`, as serving could not take every
recorded call. So is an update whose observation drops a field the entity
held, since serving keeps every field an update does not write.
recorded call. So is an update a later read shows dropped a field the
entity held, since serving keeps every field an update does not write; a write's
own response may leave fields out.
`PUT` requires every member the exemplar sent; `PATCH` and `POST` require at
least one. A field the exemplar's update changed without sending it is
explained as one of:
@@ -201,7 +202,8 @@ from the state:
repeating a name with one value is, as a call naming it matches both. A list
is compared as served from the collection of that line. A read on a page
(`limit`, `size`, `per_page` and the like, or a `_start`–`_end` range; a size
of zero bounds nothing on a line whose recording listed items under it) of a
of zero is no bound on a line whose recording listed items under it, while its
other sizes and range still bound the page) of a
whole list checks nothing once the collection holds more entities than the
page, which serving then answers from its recording.
- A read made while a modelled write to its entity or collection was still
@@ -239,7 +241,8 @@ refuses to answer with.
an update's changes confirms nothing of it, and leaves it for a later check
of that entity or collection.
- A mismatch contradicts each exercised write that no unmodelled write came
after, whatever earlier write is still pending. When an unmodelled write
after its latest call on the read's entity or collection, whatever earlier
write is still pending. When an unmodelled write
followed some of them, an item read checks those only on the members they
sent and a list is not checked again: such a write may explain the rest.
- A mismatch on an entity or collection that no write changed contradicts,
@@ -338,3 +338,15 @@ five of them on the previous rounds' own changes:
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
applies all 31 served write tools, and the gates passed again.
A sixteenth round settled four findings Greptile raised on the fifteenth round's changes, here and on the fidelity,
list-lines and list-queries branches:
- a size of zero is no bound only by itself: a range or another size on the same line still bounds the page;
- an update is refused for a dropped field only when a later read shows it dropped, since a write's own response may
leave fields out;
- interference is weighed against a tool's latest call on the read's entity or collection, not its latest call
anywhere.
Each new test fails on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the smoke
applies all 31 served write tools, and the gates passed again.
@@ -766,6 +766,23 @@ describe("paged lines", () => {
});
});
it("keeps a range's bound beside a zero size its recording shows unbounded", () => {
const books = servedFrom([
["GET", "/api/books?limit=0&_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
["GET", "/api/books/3", THIRD_BOOK],
]);
books.answer("post_books", CREATE_NEW_BOOK);
expect(
books.answer("get_books", { limit: "0", _start: "0", _end: "2" }),
).toMatchObject({
body: [FIRST_BOOK, SECOND_BOOK],
state: { source: "recording", reason: "page-exceeded" },
});
});
it("answers a range the collection has outgrown from its recording", () => {
const books = servedFrom([
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
@@ -1012,6 +1012,29 @@ describe("what a write's effect keeps", () => {
});
});
it("weighs an unmodelled write against a tool's call on the entity a read checks, not its latest call elsewhere", () => {
const tagged = (id: number, name: string, tag: string) => ({
id,
name,
tag,
});
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [tagged(1, "A", "x"), tagged(2, "B", "x")]],
["GET", "/api/books/1", tagged(1, "A", "x")],
["GET", "/api/books/2", tagged(2, "B", "x")],
["PATCH", "/api/books/1", tagged(1, "A2", "x"), 200, { name: "A2" }],
["POST", "/api/exports", { queued: true }, 202, { format: "csv" }],
["PATCH", "/api/books/2", tagged(2, "B2", "x"), 200, { name: "B2" }],
// The export came after book 1's rename, so it may explain the changed tag; the rename itself shows.
["GET", "/api/books/1", tagged(1, "A2", "y")],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: true,
contradicted_by: null,
checked_record_ordinals: [6],
});
});
it("contradicts a write made after an unmodelled one, though an earlier pending write came before it", () => {
const summary = (name: string) => [
{ id: 1, name },
@@ -1046,33 +1069,42 @@ describe("what a write's effect keeps", () => {
});
});
it("refuses an update whose recorded response dropped a field serving would keep", () => {
const subtitled = (id: number, name: string, subtitle?: string) =>
subtitle === undefined ? { id, name } : { id, name, subtitle };
const summaries = (first: string): TimedCaptureRow => [
"GET",
"/api/books",
[subtitled(1, first), subtitled(2, "B")],
];
it("refuses an update a later read shows dropped a field serving would keep", () => {
const rows: TimedCaptureRow[] = [
[
"GET",
"/api/books",
[
{ id: 1, name: "A" },
{ id: 2, name: "B" },
],
],
["GET", "/api/books/1", { id: 1, name: "A", subtitle: "s" }],
["GET", "/api/books/2", { id: 2, name: "B", subtitle: "t" }],
["PATCH", "/api/books/1", { id: 1, name: "Z" }, 200, { name: "Z" }],
[
"GET",
"/api/books",
[
{ id: 1, name: "Z" },
{ id: 2, name: "B" },
],
],
summaries("A"),
["GET", "/api/books/1", subtitled(1, "A", "s")],
["GET", "/api/books/2", subtitled(2, "B", "t")],
["PATCH", "/api/books/1", "", 204, { name: "Z" }],
["GET", "/api/books/1", subtitled(1, "Z")],
summaries("Z"),
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: false,
reason: "unexplained-change",
});
});
it("keeps an update whose partial response leaves out a field a later read still shows", () => {
const rows: TimedCaptureRow[] = [
summaries("A"),
["GET", "/api/books/1", subtitled(1, "A", "s")],
["GET", "/api/books/2", subtitled(2, "B", "t")],
["PATCH", "/api/books/1", subtitled(1, "Z"), 200, { name: "Z" }],
["GET", "/api/books/1", subtitled(1, "Z", "s")],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: true,
checked_record_ordinals: [4],
});
});
});
describe("what later calls must keep consistent", () => {
@@ -361,7 +361,7 @@ export class EnvironmentStateWriteSimulation {
return;
const exercised = exercisedBy(this.#touched, keys, borrowers);
if (borrowers !== undefined) {
this.#contradictOthers(outcome.pass, exercised, record);
if (!outcome.pass) this.#contradictOthers(keys, exercised, record);
return;
}
const pass =
@@ -372,14 +372,14 @@ export class EnvironmentStateWriteSimulation {
: undefined;
if (!pass) {
const interfered = [...exercised].filter((name) =>
this.#interferedWith(name),
this.#interferedWith(name, keys),
);
if (interfered.length === 0)
this.#mismatch({ record, index, keys, exercised, view });
// An unmodelled write after some of these writes may explain the difference: those are checked on their own members only.
else if (view.shape === "item") this.#checkMembers(view, record);
else {
this.#contradictOthers(false, exercised, record);
this.#contradictOthers(keys, exercised, record);
for (const key of keys) this.#taint.fail(key);
}
if (view.shape === "item") this.#adopt(view, record);
@@ -397,13 +397,12 @@ export class EnvironmentStateWriteSimulation {
/** A failed read contradicts the writes it exercised that no unmodelled write came after, as a read a write's effect was learned from still does the others. */
#contradictOthers(
pass: boolean,
keys: readonly string[],
exercised: ReadonlySet<string>,
record: StateRecordFacts,
): void {
if (pass) return;
for (const name of exercised)
if (!this.#interferedWith(name))
if (!this.#interferedWith(name, keys))
this.#drafts.get(name)!.contradictedBy ??= record.ordinal;
}
@@ -431,10 +430,11 @@ export class EnvironmentStateWriteSimulation {
this.#unmodelledAt.push(index);
}
/** Whether a write the gate does not model came after a tool's latest modelled call, so it may explain what a read shows of that call. */
#interferedWith(tool: string): boolean {
const last =
this.#log.findLast((write) => write.tool === tool)?.index ?? -1;
/** Whether a write the gate does not model came after a tool's latest modelled call on these targets, so it may explain what a read shows of it. */
#interferedWith(tool: string, keys: readonly string[]): boolean {
const last = Math.max(
...keys.map((key) => this.#touchedSince.get(key)?.at.get(tool) ?? -1),
);
return this.#unmodelledAt.some((index) => index > last);
}
@@ -35,26 +35,28 @@ const PAGE_SIZE_PARAMETERS = new Set([
"size",
]);
/** The most items a query's page holds: its smallest whole-number page-size value or `_start`–`_end` range width, or undefined without one. */
export const pageSize = (query: string | undefined): number | undefined => {
/** The most items a query's page holds: its smallest whole-number page-size value or `_start`–`_end` range width, or undefined without one; with `unboundedZero`, a size of zero is no bound. */
const pageLimit = (
query: string | undefined,
unboundedZero: boolean,
): number | undefined => {
let size = Infinity;
const range = { start: 0, end: Infinity };
for (const [name, value] of iterateUrlQueryParameters(query ?? "")) {
const key = name.toLowerCase();
if (!/^\d+$/u.test(value)) continue;
if (PAGE_SIZE_PARAMETERS.has(key)) size = Math.min(size, Number(value));
else if (key === "_start") range.start = Number(value);
else if (key === "_end") range.end = Number(value);
const number = Number(value);
if (PAGE_SIZE_PARAMETERS.has(key)) {
if (number > 0 || !unboundedZero) size = Math.min(size, number);
} else if (key === "_start") range.start = number;
else if (key === "_end") range.end = number;
}
const width = Math.min(size, Math.max(range.end - range.start, 0));
return Number.isFinite(width) ? width : undefined;
};
/** The most items a line's call lists: its page size, but none for a size of zero the line's own recording listed items under, as an application reading zero as no limit does. */
/** The most items a line's call lists. A size of zero is no bound on a line whose own recording listed items under it, as an application reading zero as no limit does; every other size and range still bounds the page. */
export const pageBound = (
query: string | undefined,
recorded: number,
): number | undefined => {
const size = pageSize(query);
return size === 0 && recorded > 0 ? undefined : size;
};
): number | undefined => pageLimit(query, recorded > 0);
@@ -17,8 +17,11 @@ export const shownFields = (
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
export type TouchedTargets = Map<string, Map<string, readonly string[] | null>>;
/** Per target, when the latest modelled write since its last check ended. */
export type PendingTargets = Map<string, { until: number }>;
/** Per target, when the latest modelled write since its last check ended, and each tool's latest call there. */
export type PendingTargets = Map<
string,
{ until: number; readonly at: Map<string, number> }
>;
/** Whether a modelled write to the target was still running when a read began at `start`, so the read may show either state. */
export const runningAt = (
@@ -40,19 +43,22 @@ export const exercisedBy = (
return exercised;
};
/** Record a modelled write on a target: the fields it wrote (undefined: all) and when it ended. */
/** Record a modelled write on a target: the fields it wrote (undefined: all), when it was made and when it ended. */
export const touchTarget = (
touched: TouchedTargets,
pending: PendingTargets,
target: string,
write: {
readonly index: number;
readonly tool: string;
readonly end: number;
readonly fields: readonly string[] | undefined;
},
): void => {
const held = pending.get(target);
pending.set(target, {
until: Math.max(pending.get(target)?.until ?? -Infinity, write.end),
until: Math.max(held?.until ?? -Infinity, write.end),
at: (held?.at ?? new Map()).set(write.tool, write.index),
});
const tools = touched.get(target) ?? new Map();
const before = tools.get(write.tool);
@@ -303,6 +309,11 @@ export const forgetChecked = (
pending.since.delete(key);
return;
}
for (const name of writes.keys()) if (!unseen.has(name)) writes.delete(name);
const at = pending.since.get(key)?.at;
for (const name of writes.keys())
if (!unseen.has(name)) {
writes.delete(name);
at?.delete(name);
}
pending.effects.checked(key, unseen);
};
@@ -395,6 +395,7 @@ export const applyUpdate = (
idField,
id,
startMs: startOf(context, step),
read: after.ordinal !== step.record.ordinal,
});
if (explained.unexplained.length > 0 && !unknown) {
refuse(draft, step, "unexplained-change");
@@ -58,6 +58,7 @@ const updated = (
method: "PATCH",
idField: "id",
id: "42",
read: false,
startMs: START,
...context,
});
@@ -245,6 +245,8 @@ export interface UpdateEvidence {
readonly idField: string;
readonly id: string;
readonly startMs: number | undefined;
/** Whether a read, not the write's own response, observed the entity, so a field it lacks was removed rather than left out. */
readonly read: boolean;
}
/** The rule a field the update changed without sending follows, if any. */
@@ -301,9 +303,10 @@ export const explainUpdate = (
value: evidence.after[member]!,
});
}
// Serving keeps every field an update does not write, so one the update removed is a change no rule models.
for (const field of Object.keys(evidence.before))
if (!Object.hasOwn(evidence.after, field)) unexplained.push(field);
// Serving keeps every field an update does not write, so one a read shows the update removed is a change no rule models.
if (evidence.read)
for (const field of Object.keys(evidence.before))
if (!Object.hasOwn(evidence.after, field)) unexplained.push(field);
for (const field of Object.keys(evidence.after)) {
if (
Object.hasOwn(body, field) ||