fix(environment): count no write for a collection once a read shows its entity gone

A whole list's read checks the collection, so it exercised every write
that changed it since the last check. A create whose entity an
unmodelled delete removed, and a 404 then showed gone, was still among
them, so a whole list taken afterwards confirmed a create it cannot
show. The absent entity now leaves every write's claim on its
collection, as a modelled delete's replacement does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-20 22:30:27 -07:00
1 parent 8a374b6b56
commit 40800631d2
8 files changed
+77 -32

No files matched your search

@@ -6,5 +6,7 @@
2. **Counters.** Count `number`-of-items members as item totals.
3. **Consumers.** One helper says whether a view lists all on a line; serving, the write gate's list checks and the
insert retry use it, and an argument-less call may answer on any such line.
4. **Tests and docs.** Domain and serving tests as the acceptance lists; the membership row of the environment-state
4. **Absences.** When a read shows an entity absent, drop it from every write's claim on its collection, so a write
left with none no longer counts there.
5. **Tests and docs.** Domain and serving tests as the acceptance lists; the membership row of the environment-state
docs and the list answer row of the agent-environment docs.
@@ -18,13 +18,19 @@ against the whole collection, and a create's insert rule may be retried on it, a
their recorded members. Without such a line, or when every line is one, the view is unchanged and the member is absent,
so existing states keep their values.
A whole line's read checks the collection, so it exercises every write that changed the collection since it was last
read. A write whose entity a read has since shown absent, for example a create an unmodelled delete removed and a 404
then showed gone, no longer counts there, as when a modelled delete replaces it. Otherwise a whole list taken after the
entity was gone would confirm a create it cannot show.
`numberOfElements` and similar number-of-items members (`number` with `elements`, `entries`, `items` or `results`)
count the listed entities, so a whole line's counter follows the state as `totalElements` does.
## Acceptance
- Domain: a tool with a whole line and a filtered line names the whole one; a create the whole line later lists is
confirmed; a tool whose lines are all whole, or none, names none.
confirmed; a tool whose lines are all whole, or none, names none; a whole list taken after a read showed the created
entity gone confirms no create.
- Serving: after a served create, the whole line lists the created entity with its counters, and the filtered line
keeps its recorded members.
- Measured: the six published environments compared with the application before and after, on the probes of the
@@ -3,6 +3,7 @@
- [x] Judge whole-collection membership per checked line.
- [x] Count number-of-items members.
- [x] Serve, check and place creates per line.
- [x] Drop a write from its collection once a read shows its entity absent.
- [x] Add domain and serving tests.
- [x] Document per-line membership.
- [x] Measure the six published environments before and after.
@@ -1,14 +1,15 @@
# Verification
Verified commit: 87e94d12 (this branch before this file). Executed on macOS with Node v22.23.1 (the repository pins 24.18.0 in `.nvmrc`, which was
not installed); the probes were recorded earlier against the live applications on six disposable Linux VMs.
Verified commit: 8a374b6b (this branch with the write-plan branch's head `dc5e2aad` merged in). Executed on macOS with
Node v22.23.1 (the repository pins 24.18.0 in `.nvmrc`, which was not installed); the probes were recorded earlier
against the live applications on six disposable Linux VMs.
## Gates
- `npm run build:cached`, `npm run check:fast` (typecheck and lint), `npm run format:check`, `npx knip`,
`npm run scan:todos`, `npm run docs:check`, `npm run verify:test-discovery` and `npm run mcp-catalog:check`: passed.
- `npm run jscpd`: 3.46% duplicated lines over 3601 files, under the 3.5% gate.
- `npx vitest run --project domain --project services`: 7479 passed, 5 pre-existing skips.
- `npm run jscpd`: 3.45% duplicated lines over 3646 files, under the 3.5% gate.
- `npx vitest run --project domain --project services`: 7615 passed, 5 pre-existing skips.
- `npm run test:prepare`, then `npm run test:focused --` the environment serve and export CLI boundaries, the serve
and served-writes MCP boundaries, the analysis-view CLI and MCP boundaries, the compact input schema boundary and
tool schema validity: 8 files, 78 passed.
@@ -18,37 +19,39 @@ one, with `numberOfElements` and `totalElements` as counters, and a create the w
whose lines are all whole, or none, names none) and one serving test in `environmentStateWriteAnswer` (after a served
create, the whole line lists the created book with its counters, and the filtered line keeps its recorded member).
## Six published environments before and after
## Fifty published captures
The six published environments of the fidelity comparison (NetBox, Komga, Mealie, n8n, Shlink, self-hosted Sentry),
each built from its original capture, were compared with `environment-compare` before and after this change. The
probes were recorded against each application restored from one snapshot: the fidelity comparison's capture re-run,
create/read/update/delete scenario and read of every seeded entity, and held-out sessions recorded later with values
no capture sent (two creates, updates and deletes of created and seeded entities, and the reads and lists after them).
Both builds merged the environment-compare and prefix-variance branches, which the comparison and the manifests need.
The environment-state view ran over the 50 published captures with each manifest's template rules, on builds with and
without this change. Both merged the prefix-variance branch, which some manifests need.
- 47 list views in 20 environments gain a whole line, among them Komga's books, series, collections and read lists,
BookStack's books, chapters and pages, Mealie's cookbooks, shopping lists, categories and tags, WordPress's posts and
comments, Vikunja's labels and Paperless's groups. On that line a call now lists the state's entities, including
those a task loads with `data/add` and those served writes create or delete, where it listed only the recorded
members.
- Served write tools go from 31 to 33: Komga's collection create, which a whole list after the create confirms, and
Airbyte's tag delete. Four of the gained lists belong to a type with a served write (Airbyte tags, BookStack books,
Komga collections, Linkding tags).
- Komga's list views also count `numberOfElements`. No other view or write plan changed.
## Six environments against their applications
The six environments of the fidelity comparison (NetBox, Komga, Mealie, n8n, Shlink, self-hosted Sentry) were compared
with their applications by `environment-compare` with and without this change. The probes were the comparison's capture
re-run, create/read/update/delete scenario and read of every seeded entity, and held-out sessions recorded later with
values no capture sent. Both builds merged the environment-compare and prefix-variance branches.
| Environment | Steps | Before | After | Ignoring order, before | After |
| ----------- | ----: | -----: | ----: | ---------------------: | ----: |
| NetBox | 479 | 98.5% | 98.5% | 98.5% | 98.5% |
| Komga | 600 | 94.7% | 95.2% | 94.7% | 95.5% |
| Komga | 600 | 91.5% | 91.5% | 91.5% | 91.5% |
| Mealie | 449 | 94.0% | 94.0% | 94.9% | 94.9% |
| n8n | 278 | 71.6% | 71.6% | 84.2% | 84.2% |
| Shlink | 202 | 87.1% | 87.1% | 90.1% | 90.1% |
| Sentry | 372 | 24.5% | 24.5% | 47.6% | 47.6% |
Komga is the one environment whose list tools have both kinds of line: `/api/v1/collections` lists every collection,
`?library_id=…` filters them. After the change the whole line lists created collections with `totalElements` and
`numberOfElements` following, which three list reads after creates now agree on; in two more the created collection
is listed but placed where the insert rule puts it, not where Komga sorts it (`order`). No step of any environment
agreed before and differs after.
The other environments' lists did not meet the per-line conditions or did not need them:
- NetBox's sites list was already `all`.
- Mealie's recipe lines all carry ordering or search parameters.
- Shlink's short-URL list replays (`query-varies`).
- n8n's workflow list is `list-differs`.
Their remaining differences are elsewhere: sets the application returns in varying order, writes the capture never
confirmed, preconditions such as n8n's archive-before-delete, and, for Sentry, values the capture's preparation
replaced before inspection.
No step changed. This Komga environment's capture creates, updates and deletes a collection before any whole list, so
with the write-plan branch's head its create and update are unconfirmed and serve nothing a list could show. Before
that head (at `f2b95351`), when a list taken after the delete still confirmed them, the same comparison moved Komga
from 94.7% to 95.2%: three list reads after creates agreed and two more differed only in where the created collection
was placed. None of the other five environments has a list tool with both kinds of line and a served write on its type.
+3 -2
View File
@@ -634,8 +634,9 @@ with its recording as the read plans are, 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. A 400, 404 or 410 (or a
`null` body) agrees 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
the writes that changed its entity or collection since it was last read, less
those whose entity a read since showed absent, which no longer count for the
collection; a mismatch contradicts them, or, when an unmodelled write came after them, is
checked only on the members they sent; a mismatch no write explains marks the related writes since as
`side-effect`. A plan is `served` when a read confirmed it and none
contradicted it; otherwise its `reason` is the first of its build reason
@@ -271,6 +271,11 @@ export class EnvironmentStateWriteSimulation {
this.#touched.delete(target);
if (isAbsence(record.status)) {
this.#state.remove(entity);
this.#effects.removed(
this.#touched,
target,
collectionTarget(entity.type, entity.collection),
);
return;
}
const object = this.#input.io.withBody(record.ordinal, (body) =>
@@ -177,6 +177,18 @@ export class WriteEffects {
this.#members.delete(key);
}
/** A read showed the entity absent: no write's effect on it remains, so a write left with no entity drops from the collection. */
removed(touched: TouchedTargets, entity: string, collection: string): void {
this.#changed.delete(entity);
const byTool = this.#members.get(collection);
if (byTool === undefined) return;
for (const [tool, entities] of byTool) {
if (!entities.delete(entity) || entities.size > 0) continue;
byTool.delete(tool);
touched.get(collection)?.delete(tool);
}
}
supersede(
touched: TouchedTargets,
writer: string,
@@ -513,6 +513,21 @@ describe("list lines", () => {
});
});
it("confirms no create by a whole list taken after a read showed its entity gone", () => {
const rows: TimedCaptureRow[] = [
...BOOK_INIT_ROWS,
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
// A redirected delete is not modelled; the read after it shows the book gone.
["DELETE", "/api/books/3", "", 302],
["GET", "/api/books/3", BOOK_NOT_FOUND, 404],
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
];
expect(planOf(stateOf(rows), "post_books")).toMatchObject({
served: false,
reason: "unconfirmed",
});
});
it("names no line when every line lists its whole collection, or none does", () => {
const listOf = (rows: readonly TimedCaptureRow[]) =>
stateOf(rows).views.find((view) => view.tool === "get_books");