diff --git a/docs/specs/agent-environments/write-form-calls/spec.md b/docs/specs/agent-environments/write-form-calls/spec.md index dd0b8012c9..ba78d9d6dc 100644 --- a/docs/specs/agent-environments/write-form-calls/spec.md +++ b/docs/specs/agent-environments/write-form-calls/spec.md @@ -31,7 +31,8 @@ natural-language tasks ask for, or withheld them: - **Sorted collections.** An update that writes the field its type's creates are sorted by moves its entity to where the new value sorts, in replay and serving (a bulk update moving each entity in turn), and its plan carries that `insert`. When a read listing the whole collection fails after an update and the type's creates are not sorted by a - field the update writes, replay tries the collection sorted by such a field, unless a create was placed by a sort + field the update writes, replay tries the collection sorted by such a field when the entities no exercised write + changed already follow it, as serving moves only written entities, and unless a create was placed by a sort that excludes it or a whole-collection read in collection order (not sorted by its query) that placed a create did not follow it; on a match, the type's creates and updates take that order. - **Unloaded answers.** A create's later item read is taken as its observation when it differs from the answer only in diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index aa99ee1378..ef66212e02 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -862,7 +862,9 @@ update that writes the field its type's creates are sorted by moves its entity to where the new value sorts (its own `insert`); a bulk update moves each entity it writes in turn, as replay applies them. When a read listing the whole collection fails after an update and the type's creates are not sorted by a field -that update writes, the view tries the collection sorted by such a field, unless +that update writes, the view tries the collection sorted by such a field, when +the entities no write it exercised changed already follow it, as serving moves +only the entities a write changes, and unless a create was placed by a sort that excludes it or a read listing the whole collection in its own order, not one its query sorts, that placed a create did not follow it; on a match, the type's creates diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 00e700ef83..90ae40386a 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -86,7 +86,24 @@ import { } from "./environmentStateWriteRules.js"; import { WriteTaint } from "./environmentStateWriteTaint.js"; import { writeVerdict } from "./environmentStateWriteVerdict.js"; -import { orderedBy } from "./environmentStateWriteExplain.js"; +import { compareValues, orderedBy } from "./environmentStateWriteExplain.js"; + +/** Whether members are in a sorted rule's order, ties kept in place as an insert keeps them. */ +const keptInOrder = ( + members: readonly JsonObject[], + rule: Extract, +): boolean => + members.every((member, index) => { + if (index === 0) return true; + const order = compareValues( + ownMember(members[index - 1]!, rule.field), + ownMember(member, rule.field), + ); + return ( + order !== undefined && + (rule.direction === "ascending" ? order <= 0 : order >= 0) + ); + }); const APPLY = { create: applyCreate, @@ -136,6 +153,8 @@ export class EnvironmentStateWriteSimulation { }[] = []; /** Entities a create inserted, by target, with the create's tool. */ readonly #inserted = new Map(); + /** The targets each write tool changed, which serving moves when their collection is kept sorted. */ + readonly #wrote = new Map>(); /** Creates whose insert rule a passing list check relied on, so it is no longer changed. */ readonly #placed = new Set(); /** The order a read showed a type's collections sorted in after an update, when no create of it showed one. */ @@ -443,11 +462,15 @@ export class EnvironmentStateWriteSimulation { return; } this.#effects.supersede(this.#touched, write.tool, result); - for (const target of result.targets) + const wrote = this.#wrote.get(write.tool) ?? new Set(); + this.#wrote.set(write.tool, wrote); + for (const target of result.targets) { + wrote.add(target); touchTarget(this.#touched, this.#touchedSince, target, { ...write, fields: result.fields, }); + } this.#log.push(write); if (result.inserted === undefined) return; this.#inserted.set(result.inserted, write.tool); @@ -935,8 +958,20 @@ export class EnvironmentStateWriteSimulation { ), ), ) ?? []; + // Serving moves only the entities an update writes, so the others must already be in the order tried. + const kept = this.#state + .members(view.type, line.collection) + .filter((entity) => { + const target = entityTarget(entity.type, entity.collection, entity.id); + return ![...exercised].some((name) => + this.#wrote.get(name)?.has(target), + ); + }) + .map((entity) => entity.fields); for (const rule of rules.filter( - (candidate) => current === undefined || !same(candidate, current), + (candidate) => + (current === undefined || !same(candidate, current)) && + keptInOrder(kept, candidate), )) { // A create placed by another sort shows the collection is not kept in this order. if ( diff --git a/src/domain/analysisView/environmentStateFormWrites.test.ts b/src/domain/analysisView/environmentStateFormWrites.test.ts index df19c858a9..63fa375b9e 100644 --- a/src/domain/analysisView/environmentStateFormWrites.test.ts +++ b/src/domain/analysisView/environmentStateFormWrites.test.ts @@ -17,6 +17,7 @@ import { SHELF_SORTED_ROWS, TITLE_ORDER_ROWS, UNLOADED_ANSWER_ROWS, + UNSORTED_SEED_ROWS, } from "../../../tests/fixtures/environmentStateFormWrites.js"; import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js"; import { mergedCreatePlan } from "./environmentStateWriteCreateCalls.js"; @@ -162,6 +163,12 @@ describe("collections kept sorted", () => { }); }); + it("learns no sort from a rename that moving the renamed entity alone cannot reach", () => { + const plan = planOf(UNSORTED_SEED_ROWS, "put_folders_by_id"); + expect(plan.served).toBe(false); + expect(plan.insert).toBeNull(); + }); + it("serves a bulk update whose recordings echo the empty values each call sent", () => { expect(planOf(BULK_EMPTY_TAG_ROWS, "patch_labels")).toMatchObject({ served: true, diff --git a/tests/fixtures/environmentStateFormWrites.ts b/tests/fixtures/environmentStateFormWrites.ts index 33077fe913..c5575c782c 100644 --- a/tests/fixtures/environmentStateFormWrites.ts +++ b/tests/fixtures/environmentStateFormWrites.ts @@ -422,3 +422,20 @@ export const SHELF_SORTED_ROWS: readonly TimedCaptureRow[] = (() => { ["GET", "/api/books", [renamed, alpha, bravo]], ]; })(); + +/** Seeded folders listed out of title order, then a rename after which a list read happens to show title order. */ +export const UNSORTED_SEED_ROWS: readonly TimedCaptureRow[] = (() => { + const [charlie, bravo, alpha] = [ + folder(1, "Charlie"), + folder(2, "Bravo"), + folder(3, "Alpha"), + ]; + const renamed = folder(1, "Zulu", 2); + return [ + ["GET", "/api/folders", [charlie, bravo, alpha]], + ["GET", "/api/folders/1", charlie], + ["PUT", "/api/folders/1", renamed, 200, { title: "Zulu", version: 1 }], + ["GET", "/api/folders/1", renamed], + ["GET", "/api/folders", [alpha, bravo, renamed]], + ]; +})();