fix(environment): never rule an entity's identity, and read a zero page size as no bound

Greptile raised these on the list-queries branch against this branch's code.
- An update's identity member never takes a rule. Sent as the path's id it is the identity member; sent as another value the application ignored it is accepted with that value and stores nothing, so the exemplar's id is no longer written into the entity another call names; a stored id that changed is unexplained.
- A page size of zero bounds nothing, so a whole line recorded with `limit=0` answers from the state instead of replaying stale members.

The verdicts over the 50 captures are unchanged plan by plan, and the smoke applies all 31 served write tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-21 11:01:31 -07:00
1 parent 264ce41062
commit 0ae64400ee
5 files changed
+62 -7

No files matched your search

@@ -102,7 +102,10 @@ naming the exemplar's collection.
**Updates.** Body members follow the same accepted and not-accepted rules,
except that a member the application stored differently is accepted only with
the value a recorded call sent it with, and then stores the value that call
recorded (its `constant` rule carries it).
recorded (its `constant` rule carries it). The type's identity field never
takes a rule: sent as the path's id it is the identity member, sent as another
value the application ignored it is accepted with that value and stores
nothing, and a stored id that changed is `unexplained-change`.
`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:
@@ -191,7 +194,7 @@ from the state:
different values is never chosen, since no call's arguments can name it; one
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) of a
(`limit`, `size`, `per_page` and the like, a size of zero bounding nothing, or a `_start`–`_end` range) 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
@@ -310,3 +310,12 @@ A thirteenth round settled seven findings Greptile raised on this branch, the fi
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 fourteenth round settled two findings Greptile raised on the list-queries branch against this branch's code:
- the type's identity field never becomes a constant: an update whose id member the application ignored no longer
writes the exemplar's id into the entity another call names;
- a page size of zero bounds nothing, so a whole line recorded with `limit=0` answers from the state.
Both tests fail 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.
@@ -83,6 +83,29 @@ describe("served creates", () => {
});
describe("served updates and deletes", () => {
it("keeps an entity's identity when the application ignored the id an update sent", () => {
const renamed = renamedBook(FIRST_BOOK, "Renamed Book", 3);
const books = servedFrom([
...BOOK_INIT_ROWS,
[
"PATCH",
"/api/books/1",
renamed,
200,
{ id: 999, name: "Renamed Book" },
],
["GET", "/api/books/1", renamed],
]);
const answer = books.answer("patch_books_by_id", {
id: "2",
body: { id: 999, name: "Second Edition" },
});
expect(answer.body).toMatchObject({ id: 2, name: "Second Edition" });
expect(books.answer("get_books_by_id", { id: "2" }).body).toMatchObject({
id: 2,
});
});
const SECOND_EDITION = renamedBook(SECOND_BOOK, "Second Edition", 13);
const RENAME_SECOND = { id: "2", body: { name: "Second Edition" } };
@@ -671,6 +694,21 @@ describe("paged lines", () => {
});
});
it("answers a whole list a zero limit names from the state, as zero lists every match", () => {
const books = servedFrom([
["GET", "/api/books?limit=0", [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],
]);
const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book;
expect(books.answer("get_books", { limit: "0" })).toMatchObject({
body: [FIRST_BOOK, SECOND_BOOK, created],
state: { source: "state", membership: "all", listed: 3 },
});
});
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]],
@@ -186,8 +186,10 @@ export const pageSize = (query: string | undefined): number | undefined => {
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);
// A size of zero bounds nothing: applications read it as no limit, as a whole line naming it shows.
if (PAGE_SIZE_PARAMETERS.has(key)) {
if (Number(value) > 0) size = Math.min(size, Number(value));
} else if (key === "_start") range.start = Number(value);
else if (key === "_end") range.end = Number(value);
}
const width = Math.min(size, Math.max(range.end - range.start, 0));
@@ -280,10 +280,14 @@ export const explainUpdate = (
const { body } = evidence.call;
const rules: FieldRule[] = [];
const echoed = new Set<string>();
const unexplained: string[] = [];
let identity: string | null = null;
for (const [member, sent] of Object.entries(body)) {
if (member === evidence.idField && scalarText(sent) === evidence.id) {
identity = member;
// The identity is the entity's key: the path's id repeated is the identity member; another value is one the application ignored, or used to re-key the entity, which no rule models.
if (member === evidence.idField) {
if (scalarText(sent) === evidence.id) identity = member;
else if (!same(evidence.after[member], evidence.before[member]))
unexplained.push(member);
continue;
}
if (!Object.hasOwn(evidence.after, member)) continue;
@@ -297,7 +301,6 @@ export const explainUpdate = (
value: evidence.after[member]!,
});
}
const unexplained: string[] = [];
for (const field of Object.keys(evidence.after)) {
if (
Object.hasOwn(body, field) ||