diff --git a/docs/specs/agent-environments/state-writes/spec.md b/docs/specs/agent-environments/state-writes/spec.md index 14d190ac5e..ebd713b38b 100644 --- a/docs/specs/agent-environments/state-writes/spec.md +++ b/docs/specs/agent-environments/state-writes/spec.md @@ -65,7 +65,9 @@ identity the client sends is an echo: 1. **Echo.** A body member of the same name with an equal value. 2. **Copy.** A body string of 8 or more characters, found at a JSON pointer of the body (the shallowest match). -3. **Path.** The value of a path parameter of the call. +3. **Path.** The value of a path parameter of the call, percent-decoded. + Serving decodes it from the collection the call names, whichever spelling + the call uses. 4. **Slug.** The slug of a body string member: lower case, runs of other characters joined by `-`. 5. **Identity.** The type's `id_field` is generated. A sequence when it is an @@ -107,7 +109,9 @@ explained as one of: - an increment by one; - a slug of an echoed member. -Any other change is `unexplained-change`. +Any other change is `unexplained-change`. An increment or a slug applies to +every call that could take it, so a call that leaves such a field as it was +while another call of the tool changes it is also `unexplained-change`. **Deletes.** A delete removes the entity. Its response is the exemplar's response, with the entity re-rendered when the response carries it. @@ -115,7 +119,9 @@ response, with the entity re-rendered when the response carries it. **Documents.** A document write merges the body's members (replaces them, for `PUT`) into the document object that holds most of them: the root or one member. Document reads answer only on their recorded line, so a write is -served only at the path the state holds the document at. +served only at the path the state holds the document at. A recorded write +whose body serving would refuse (a member of another JSON type than the +document holds) is `unexplained-change`. **Responses.** A write answers with the status and content type of one recorded response: @@ -165,7 +171,9 @@ members they sent. from the state: - an item read of a state-backed item view; -- a list or document read on one of its view's checked request lines. +- a list or document read on one of its view's checked request lines, + matched as serving matches a call to a line: path and query values + decoded, the query in any order. **Comparison.** The read's answer is the full response serving would give from the copy: an item read's entity rendered into its item view's base recording, @@ -190,7 +198,8 @@ body, for an entity the copy does not hold agrees with it. checked again. - A list that fails only for where one create placed its entity passes when another of that create's insert candidates reproduces it, which then - becomes the plan's rule. + becomes the plan's rule, unless an earlier list check relied on the rule + (an item read's confirmation does not fix it). - The read that supplied a create's or update's observation does not check it. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 81c696104b..ebcab44e3c 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -620,8 +620,9 @@ echo when stored as sent; a member stored differently is accepted only with its recorded value (`body.fixed`) and stores the value the call that showed it recorded (the `value` of its `constant` rule), and a field it changed without sending must be a clock, an increment or a slug, or the plan is -`unexplained-change`. Every member the first call sent is required, except by -`PATCH` and `POST` updates. +`unexplained-change`; an increment or slug must hold for every call of the +tool. Every member the first call sent is required, except by `PATCH` and +`POST` updates. The view then replays the timeline on a copy of the initial state. Modelled writes apply their effect; only a `2xx` write is modelled or an example, and diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index 0483bf6bd8..a2cf59c0b9 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -17,9 +17,13 @@ import { renamedBook, type Book, } from "../../../tests/fixtures/environmentStateWrites.js"; +import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js"; import type { ReplayArguments } from "./environmentReplay.js"; import { servedCatalog } from "./environmentState.js"; -import { environmentToolDefinitions } from "./environmentTools.js"; +import { + environmentToolArguments, + environmentToolDefinitions, +} from "./environmentTools.js"; /** The book the first served create of "New Book" makes: the next id, one second past the served clock's start. */ const NEW_BOOK = book(3, "New Book", bookClock(13)); @@ -355,3 +359,52 @@ describe("paged list lines", () => { }); }); }); + +describe("path values", () => { + const orgBook = (id: number, org: string, name: string): Book => ({ + id, + org, + name, + }); + const ALPHA = orgBook(1, "my org1", "Alpha"); + const BETA = orgBook(2, "acme2", "Beta"); + const GAMMA = orgBook(3, "my org1", "Gamma"); + const DELTA = orgBook(4, "acme2", "Delta"); + /** Books under two organisations, one whose name the paths percent-encode, and a create under each. */ + const ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/orgs/my%20org1/books", [ALPHA]], + ["GET", "/api/orgs/acme2/books", [BETA]], + ["GET", "/api/orgs/my%20org1/books/1", ALPHA], + ["GET", "/api/orgs/acme2/books/2", BETA], + ["POST", "/api/orgs/my%20org1/books", GAMMA, 201, { name: "Gamma" }], + ["GET", "/api/orgs/my%20org1/books", [ALPHA, GAMMA]], + ["POST", "/api/orgs/acme2/books", DELTA, 201, { name: "Delta" }], + ["GET", "/api/orgs/acme2/books", [BETA, DELTA]], + ]; + + it("stores a parent path value decoded, as the gate learned it, whichever spelling the call uses", () => { + for (const spelling of ["my%20org1", "my org1"]) { + const books = servedFrom(ROWS); + const tool = books.source.manifest.tools.find( + (candidate) => candidate.method === "POST", + )!; + expect(books.state.writes.get(tool.name)).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + expect.objectContaining({ field: "org", rule: "path" }), + ]), + }); + const [org] = environmentToolArguments(tool).path.values(); + books.answer(tool.name, { [org!]: spelling, body: { name: "Epsilon" } }); + const created = books.store + .snapshot() + .rea_state.entities.find( + (entity) => entity.fields["name"] === "Epsilon", + ); + expect(created).toMatchObject({ + collection: "/api/orgs/my%20org1/books", + fields: { org: "my org1" }, + }); + } + }); +}); diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 2d8a4a6439..72cf4824d7 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -1,6 +1,6 @@ import { - bodyProblem, createdFields, + documentBodyProblem, updatedEntity, updatedFields, valueAt, @@ -9,10 +9,11 @@ import { type GenerateValue, } from "../../domain/analysisView/environmentStateWriteApply.js"; import { insertIndex } from "../../domain/analysisView/environmentStateWriteExplain.js"; -import type { - GeneratedRule, - StateWritePlan, - WriteCall, +import { + pathValuesOf, + type GeneratedRule, + type StateWritePlan, + type WriteCall, } from "../../domain/analysisView/environmentStateWriteRules.js"; import { formatClock, @@ -23,7 +24,6 @@ import { spelledLike, } from "../../domain/analysisView/environmentStateWriteValues.js"; import { - at, isObject, scalarText, type JsonObject, @@ -275,7 +275,12 @@ const answerCreate = ( collection: members, observed, }); - const fields = createdFields(plan, observed, call, values.generate); + // The gate learned path rules from the recorded path, decoded; the collection is this call's. + const created = { + ...call, + pathValues: pathValuesOf(tool.path_template, collection) ?? call.pathValues, + }; + const fields = createdFields(plan, observed, created, values.generate); if (!fields.ok) return err(refused(tool, fields.error)); const field = unloadableMember(fields.value); if (field !== undefined) return err(unloadable(tool, `Field ${field}`)); @@ -439,17 +444,12 @@ const documentTarget = ( `The state holds this document only at ${held.path}; the environment models writes to it only there.`, ), ); - const pointer = plan.observation?.pointer ?? ""; - const members = (value: JsonValue | undefined): JsonObject => { - const object = value === undefined ? undefined : at(value, pointer); - return isObject(object) ? object : {}; - }; const initial = serving.store.state.documents.find( (document) => document.tool === held.tool, ); - // A member a replacing write dropped keeps the type the capture showed. - const problem = bodyProblem(plan.body, call, { - entity: { ...members(initial?.value), ...members(held.value) }, + const problem = documentBodyProblem(plan, call, { + initial: initial?.value, + current: held.value, }); return problem === undefined ? ok(held) : err(refused(tool, problem)); }; diff --git a/src/application/environment/environmentStateWriteRefusals.test.ts b/src/application/environment/environmentStateWriteRefusals.test.ts index 2cda9a29f4..5fedbadc0f 100644 --- a/src/application/environment/environmentStateWriteRefusals.test.ts +++ b/src/application/environment/environmentStateWriteRefusals.test.ts @@ -82,6 +82,19 @@ describe("served document writes", () => { expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(blue); }); + it("leaves a document write unserved when its recording changed a member's JSON type, which serving would refuse", () => { + const retyped = { theme: 5, meta: LIGHT.meta }; + const settings = servedFrom([ + ["GET", "/api/orgs/1/settings", DARK], + ["PATCH", "/api/orgs/1/settings", retyped, 200, retyped], + ["GET", "/api/orgs/1/settings", retyped], + ]); + expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + }); + it("changes the document only at the path the state holds it", () => { const settings = servedFrom(SETTINGS_ROWS); expectRefusedUnchanged(settings, [ diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts index deb9c3986d..ab961da8bc 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts @@ -10,6 +10,7 @@ import { FIRST_BOOK, SECOND_BOOK, THIRD_BOOK, + book, bookClock, environmentStateViewOf, renamedBook, @@ -99,6 +100,29 @@ describe("reads compared as the served environment answers them", () => { gated(deletedFromFiltered([FIRST_BOOK]), "delete_books_by_id"), ).toMatchObject({ served: true, checked_record_ordinals: [4] }); }); + + /** A delete confirmed by an item read, then the filtered list recorded with its query reordered. */ + const deletedThenReordered = (listed: readonly Book[]): TimedCaptureRow[] => [ + ["GET", "/api/books/1", FIRST_BOOK], + ["GET", "/api/books/2", SECOND_BOOK], + ["GET", "/api/books?shelf=a&sort=id", [FIRST_BOOK, SECOND_BOOK]], + ["DELETE", "/api/books/2", "", 204], + ["GET", "/api/books/2", BOOK_NOT_FOUND, 404], + ["GET", "/api/books?sort=id&shelf=a", [...listed]], + ]; + + it("checks a list read on a checked line however its query is ordered, as serving matches it", () => { + // The item read already checked the delete, so a list still showing the book marks it a side effect. + expect( + gated( + deletedThenReordered([FIRST_BOOK, SECOND_BOOK]), + "delete_books_by_id", + ), + ).toMatchObject({ served: false, reason: "side-effect" }); + expect( + gated(deletedThenReordered([FIRST_BOOK]), "delete_books_by_id"), + ).toMatchObject({ served: true, checked_record_ordinals: [4] }); + }); }); describe("update constants", () => { @@ -162,3 +186,67 @@ describe("write examples", () => { }); }); }); + +describe("update rules learned from a later call", () => { + const versioned = (entry: Book, version: number): Book => ({ + ...entry, + version, + }); + /** Two renames, of the first and the second book, each answered at the version given. */ + const renames = (first: number, second: number): TimedCaptureRow[] => { + const firstAfter = versioned( + renamedBook(FIRST_BOOK, "Renamed Book", 3), + first, + ); + const secondAfter = versioned( + renamedBook(SECOND_BOOK, "Second Edition", 5), + second, + ); + return [ + [ + "GET", + "/api/books", + [versioned(FIRST_BOOK, 1), versioned(SECOND_BOOK, 1)], + ], + ["GET", "/api/books/1", versioned(FIRST_BOOK, 1)], + ["GET", "/api/books/2", versioned(SECOND_BOOK, 1)], + ["PATCH", "/api/books/1", firstAfter, 200, { name: "Renamed Book" }], + ["GET", "/api/books/1", firstAfter], + ["PATCH", "/api/books/2", secondAfter, 200, { name: "Second Edition" }], + ["GET", "/api/books/2", secondAfter], + ]; + }; + + it("leaves an update unserved when one call increments a field another left as it was", () => { + for (const [first, second] of [ + [1, 2], + [2, 1], + ] as const) + expect(gated(renames(first, second), "patch_books_by_id")).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + expect(gated(renames(2, 2), "patch_books_by_id")).toMatchObject({ + served: true, + rules: expect.arrayContaining([{ field: "version", rule: "increment" }]), + checked_record_ordinals: [4, 6], + }); + }); +}); + +describe("insert rules", () => { + it("still learns where a list places a create after an item read confirmed it", () => { + const alpha = book(3, "Alpha", bookClock(3)); + const rows: TimedCaptureRow[] = [ + ...BOOK_INIT_ROWS, + ["POST", "/api/books", alpha, 201, { name: "Alpha" }], + ["GET", "/api/books/3", alpha], + ["GET", "/api/books", [alpha, FIRST_BOOK, SECOND_BOOK]], + ]; + expect(gated(rows, "post_books")).toMatchObject({ + served: true, + insert: { rule: "sorted", field: "name", direction: "ascending" }, + checked_record_ordinals: [4, 5], + }); + }); +}); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 7638542184..5ae79317c3 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -18,9 +18,11 @@ import { checkDocumentRead, checkItemRead, checkListRead, + checkedLines, collectionTarget, entityTarget, isAbsence, + requestLine, succeeded, withBodies, writtenFieldChecks, @@ -60,9 +62,6 @@ export interface WriteSimulationInput { readonly io: WriteIo; } -const requestLine = (record: StateRecordFacts): string => - record.path + (record.query ?? ""); - const APPLY = { create: applyCreate, update: applyUpdate, @@ -82,7 +81,7 @@ export class EnvironmentStateWriteSimulation { readonly #toolOf = new Map(); readonly #types = new Map(); /** Each list or document view's checked request lines, with the recording a call of the line is answered on. */ - readonly #checkedLines = new Map>(); + readonly #checkedLines: ReadonlyMap>; readonly #observations = new Set(); readonly #taint: WriteTaint; /** Modelled writes per target since its last check, with the fields each wrote (null: all). */ @@ -99,6 +98,8 @@ export class EnvironmentStateWriteSimulation { }[] = []; /** Entities a create inserted, by target, with the create's tool. */ readonly #inserted = new Map(); + /** Creates whose insert rule a passing list check relied on, so it is no longer changed. */ + readonly #placed = new Set(); constructor(input: WriteSimulationInput) { this.#input = input; @@ -108,22 +109,7 @@ export class EnvironmentStateWriteSimulation { for (const tool of input.tools) for (const ordinal of tool.record_ordinals) this.#toolOf.set(ordinal, tool); - const byOrdinal = new Map( - input.records.map((record) => [record.ordinal, record]), - ); - for (const view of input.views.values()) - if (view.shape !== "recorded" && view.shape !== "item") - this.#checkedLines.set( - view.tool, - new Map( - view.checked_record_ordinals.flatMap((ordinal) => { - const record = byOrdinal.get(ordinal); - return record === undefined - ? [] - : [[requestLine(record), ordinal] as const]; - }), - ), - ); + this.#checkedLines = checkedLines(input); this.#taint = new WriteTaint( [...this.#types.keys()], new Map( @@ -156,6 +142,7 @@ export class EnvironmentStateWriteSimulation { contradictedBy: undefined, sideEffect: false, calls: 0, + updates: [], }); } this.#input.records.forEach((record, index) => this.#step(record, index)); @@ -188,6 +175,8 @@ export class EnvironmentStateWriteSimulation { observed: (ordinal) => this.#observations.add(ordinal), unknown: (type, collection, id) => this.#taint.unknown(type, collection, id), + initialDocument: (tool) => + this.#input.documents.find((document) => document.tool === tool)?.value, }; } @@ -363,8 +352,10 @@ export class EnvironmentStateWriteSimulation { outcome.pass || (view.shape === "list" && this.#retryInsert(view, record, exercised)); if (pass) - for (const name of exercised) + for (const name of exercised) { this.#drafts.get(name)!.confirmed.push(record.ordinal); + if (view.shape === "list") this.#placed.add(name); + } else if (this.#interfered(keys)) { // An unmodelled write since these writes may explain the difference: check their own members only. if (view.shape === "item") this.#checkMembers(view, record); @@ -488,7 +479,8 @@ export class EnvironmentStateWriteSimulation { if (creates.size !== 1) return false; const [name] = creates; const draft = this.#drafts.get(name!)!; - if (!exercised.has(name!) || draft.confirmed.length > 0) return false; + // An item read confirms the entity, not where a list places it. + if (!exercised.has(name!) || this.#placed.has(name!)) return false; for (const candidate of draft.candidates.slice(1)) { for (const entity of inserted) this.#state.remove(entity); for (const entity of inserted) { diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index cb18769f8e..160c117c7b 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -204,9 +204,8 @@ export const same = ( : canonicalJson(left, "Environment state") === canonicalJson(right, "Environment state"); -/** The decoded final path segment, which an item request names its entity by. */ -export const lastSegmentValue = (path: string): string => { - const segment = segmentsOf(path).at(-1) ?? ""; +/** A path segment percent-decoded, or as written when it does not decode. */ +export const decodedSegment = (segment: string): string => { try { return decodeURIComponent(segment); } catch { @@ -214,6 +213,10 @@ export const lastSegmentValue = (path: string): string => { } }; +/** The decoded final path segment, which an item request names its entity by. */ +export const lastSegmentValue = (path: string): string => + decodedSegment(segmentsOf(path).at(-1) ?? ""); + /** A path's non-empty segments, so a trailing slash names the same collection. */ export const normalizedPath = (path: string): string => `/${segmentsOf(path).join("/")}`; diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 5733a4e3c6..485502e9d4 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -236,6 +236,26 @@ export const updatedFields = ( return ok(written); }; +/** Why a document write's body does not fit what the capture showed: members its plan accepts, as the JSON types the document holds, a member a replacing write dropped keeping the type the initial document showed. */ +export const documentBodyProblem = ( + plan: Pick, + call: WriteCall, + documents: { + readonly initial: JsonValue | undefined; + readonly current: JsonValue; + }, +): string | undefined => { + if (plan.body === null) return "The tool has no modelled body."; + const pointer = plan.observation?.pointer ?? ""; + const members = (value: JsonValue | undefined): JsonObject => { + const object = value === undefined ? undefined : at(value, pointer); + return isObject(object) ? object : {}; + }; + return bodyProblem(plan.body, call, { + entity: { ...members(documents.initial), ...members(documents.current) }, + }); +}; + /** A document after a write: the body's members merged into, or replacing, the object at the pointer. */ export const writtenDocument = ( plan: Pick, diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index d46419548a..0bf41bff59 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -1,8 +1,13 @@ import type { JsonValue } from "../jsonValue.js"; -import { compositeKey } from "../unicodeCodePointOrder.js"; +import { iterateUrlQueryParameters } from "../recordedTransportUrl.js"; +import { + compareUnicodeCodePoints as compare, + compositeKey, +} from "../unicodeCodePointOrder.js"; import { at, collectionPathOf, + decodedSegment, isObject, lastSegmentValue, listItems, @@ -11,14 +16,57 @@ import { replaceAt, same, scalarText, + segmentsOf, type JsonObject, type StateDocument, type StateEntity, type StateItemView, type StateListView, type StateRecordFacts, + type StateToolFacts, + type StateView, } from "./environmentStateRules.js"; +/** + * A read's request line as serving names one: its path segments and query values decoded, the + * query in any order. A query repeating a name is no call's arguments, so it keeps its spelling. + */ +export const requestLine = (record: StateRecordFacts): string => { + const query = [...iterateUrlQueryParameters(record.query ?? "")]; + if (new Set(query.map(([name]) => name)).size !== query.length) + return compositeKey([record.path, record.query ?? ""]); + return compositeKey([ + segmentsOf(record.path).map(decodedSegment), + query.sort(([left], [right]) => compare(left, right)), + ]); +}; + +/** Each list or document view's checked lines, by request line, as serving answers a call: from the first checked line it names, in the tool's recorded order. */ +export const checkedLines = (input: { + readonly tools: readonly StateToolFacts[]; + readonly records: readonly StateRecordFacts[]; + readonly views: ReadonlyMap; +}): Map> => { + const byOrdinal = new Map( + input.records.map((record) => [record.ordinal, record]), + ); + const tools = new Map(input.tools.map((tool) => [tool.name, tool])); + const byView = new Map>(); + for (const view of input.views.values()) { + if (view.shape === "recorded" || view.shape === "item") continue; + const checked = new Set(view.checked_record_ordinals); + const lines = new Map(); + for (const ordinal of tools.get(view.tool)?.record_ordinals ?? []) { + const record = byOrdinal.get(ordinal); + if (!checked.has(ordinal) || record === undefined) continue; + const line = requestLine(record); + if (!lines.has(line)) lines.set(line, ordinal); + } + byView.set(view.tool, lines); + } + return byView; +}; + export const entityTarget = (type: string, collection: string, id: string) => `e${compositeKey([type, collection, id])}`; export const collectionTarget = (type: string, collection: string) => diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index 97bec228ff..032fbbacaf 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -13,6 +13,7 @@ import { type StateView, } from "./environmentStateRules.js"; import { + documentBodyProblem, updatedEntity, updatedFields, writtenDocument, @@ -34,6 +35,7 @@ import { } from "./environmentStateWriteObserve.js"; import { pathValuesOf, + type FieldRule, type InsertRule, type StateWritePlan, type WriteCall, @@ -52,6 +54,11 @@ export interface WriteDraft { contradictedBy: number | undefined; sideEffect: boolean; calls: number; + /** The fields each modelled update call showed and sent, which later rules must agree with. */ + readonly updates: { + readonly shown: ReadonlySet; + readonly sent: ReadonlySet; + }[]; } /** Response bodies, request bodies and budget the effects read. */ @@ -77,6 +84,8 @@ export interface EffectContext { readonly observed: (ordinal: number) => void; /** Whether an unmodelled write may have changed the entity since the state last saw it. */ readonly unknown: (type: string, collection: string, id: string) => boolean; + /** A document's value in the initial state, which serving also reads member types from. */ + readonly initialDocument: (tool: string) => JsonValue | undefined; } /** One recorded write: the record, its timeline index, its tool, and whether its plan only takes the recorded outcome. */ @@ -233,6 +242,28 @@ const refineUpdate = ( ]; }; +/** Whether a rule changes its field on calls that do not send it: serving applies it to every call that could. */ +const unsentChange = (rule: FieldRule): boolean => + rule.rule === "increment" || rule.rule === "slug"; + +/** Whether a rule a later call showed would have changed a field an earlier call showed unchanged without sending. */ +const contradictsEarlierCalls = ( + draft: WriteDraft, + rules: readonly FieldRule[], +): boolean => { + const ruled = new Set(draft.plan.rules.map((rule) => rule.field)); + return rules.some( + (rule) => + unsentChange(rule) && + !ruled.has(rule.field) && + draft.updates.some( + ({ shown, sent }) => + shown.has(rule.field) && + (rule.rule !== "slug" || sent.has(rule.from)), + ), + ); +}; + /** Build an update plan from its exemplar, or add what a later call showed. */ const buildUpdate = ( context: EffectContext, @@ -321,6 +352,15 @@ export const applyUpdate = ( refuse(draft, step, "unexplained-change"); return recorded(); } + // Rules for unsent changes hold for every call, so each call must agree with those of the others. + if ( + built(draft) && + !unknown && + contradictsEarlierCalls(draft, explained.rules) + ) { + refuse(draft, step, "unexplained-change"); + return recorded(); + } buildUpdate(context, draft, step, { after, explained, idField }); // Serving reads the plan's observation, so the gate does too; generated values are this call's. const written = updatedFields( @@ -329,8 +369,21 @@ export const applyUpdate = ( { ...call, id }, (rule) => after.object[rule.field] ?? null, ); - if (!written.ok) refuse(draft, step, "unexplained-change"); - if (!written.ok || unknown) return recorded(); + const disagrees = + written.ok && + draft.plan.rules.some( + (rule) => + unsentChange(rule) && + Object.hasOwn(after.object, rule.field) && + !same(written.value[rule.field], after.object[rule.field]), + ); + if (!written.ok || (disagrees && !unknown)) + refuse(draft, step, "unexplained-change"); + if (!written.ok || disagrees || unknown) return recorded(); + const shown = Object.keys(after.object); + const sentMembers = Object.keys(body); + context.io.retain(64 + [...shown, ...sentMembers].join("").length * 2); + draft.updates.push({ shown: new Set(shown), sent: new Set(sentMembers) }); context.io.retain(64 + JSON.stringify(written.value).length * 2); context.state.put(updatedEntity(entity, written.value)); // A later read that also shows an unmodelled change checks only the members this call sent. @@ -412,7 +465,18 @@ export const applyDocument = ( identity: null, }, }); - const written = writtenDocument(draft.plan, value, callOf(step, body)); + const call = callOf(step, body); + // Serving refuses a body its check refuses, so such a call cannot confirm the plan. + if ( + documentBodyProblem(draft.plan, call, { + initial: context.initialDocument(tool), + current: value, + }) !== undefined + ) { + refuse(draft, step, "unexplained-change"); + return UNKNOWN; + } + const written = writtenDocument(draft.plan, value, call); if (!written.ok) { refuse(draft, step, "no-observation"); return UNKNOWN;