From 40800631d2dca5d6439d333bd3fb65f1de7e314a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 22:30:27 -0700 Subject: [PATCH] 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 --- .../agent-environments/list-lines/plan.md | 4 +- .../agent-environments/list-lines/spec.md | 8 ++- .../agent-environments/list-lines/tasks.md | 1 + .../list-lines/verification.md | 59 ++++++++++--------- docs/web-network-captures.md | 5 +- .../EnvironmentStateWriteSimulation.ts | 5 ++ .../environmentStateWriteChecks.ts | 12 ++++ .../environmentStateWrites.test.ts | 15 +++++ 8 files changed, 77 insertions(+), 32 deletions(-) diff --git a/docs/specs/agent-environments/list-lines/plan.md b/docs/specs/agent-environments/list-lines/plan.md index f7ca3db95e..53046a4bd8 100644 --- a/docs/specs/agent-environments/list-lines/plan.md +++ b/docs/specs/agent-environments/list-lines/plan.md @@ -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. diff --git a/docs/specs/agent-environments/list-lines/spec.md b/docs/specs/agent-environments/list-lines/spec.md index 810d6136fe..68427120b2 100644 --- a/docs/specs/agent-environments/list-lines/spec.md +++ b/docs/specs/agent-environments/list-lines/spec.md @@ -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 diff --git a/docs/specs/agent-environments/list-lines/tasks.md b/docs/specs/agent-environments/list-lines/tasks.md index 2f98360ecb..cac18cf2bd 100644 --- a/docs/specs/agent-environments/list-lines/tasks.md +++ b/docs/specs/agent-environments/list-lines/tasks.md @@ -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. diff --git a/docs/specs/agent-environments/list-lines/verification.md b/docs/specs/agent-environments/list-lines/verification.md index 51f309e23b..c09da3b102 100644 --- a/docs/specs/agent-environments/list-lines/verification.md +++ b/docs/specs/agent-environments/list-lines/verification.md @@ -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. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index f3947372f0..3a9204eb8a 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -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 diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 9bfedfd8df..1115da8adc 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -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) => diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index 5105f67132..f9eb379726 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -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, diff --git a/src/domain/analysisView/environmentStateWrites.test.ts b/src/domain/analysisView/environmentStateWrites.test.ts index 42eb0853de..6dba69076a 100644 --- a/src/domain/analysisView/environmentStateWrites.test.ts +++ b/src/domain/analysisView/environmentStateWrites.test.ts @@ -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");