fix(environment): make the write gate and serving agree on lines, rules, inserts and documents

Five places where the gate approved a write serving would answer
differently:
- list and document reads are matched to checked lines as serving
  matches calls (path and query values decoded, the query in any
  order), so a reordered query no longer hides a contradiction;
- an increment or slug applies to every call, so a plan is
  unexplained-change when one call changes such a field and another
  leaves it as it was, in either order;
- an item read confirming a create no longer stops a later list from
  choosing another insert candidate; only a list check fixes the rule;
- the gate runs serving's document body check (accepted members, JSON
  types from the held and initial document), so a recorded write that
  serving would refuse leaves the plan unserved;
- a served create decodes its path values from the collection the call
  names, as the gate decoded the recorded path, whichever spelling the
  call used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-20 14:12:34 -07:00
1 parent df2bded16e
commit 2086da6cc7
11 files changed
+343 -52

No files matched your search

@@ -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.
+3 -2
View File
@@ -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
@@ -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" },
});
}
});
});
@@ -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));
};
@@ -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, [
@@ -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],
});
});
});
@@ -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<number, StateToolFacts>();
readonly #types = new Map<string, StateType>();
/** Each list or document view's checked request lines, with the recording a call of the line is answered on. */
readonly #checkedLines = new Map<string, ReadonlyMap<string, number>>();
readonly #checkedLines: ReadonlyMap<string, ReadonlyMap<string, number>>;
readonly #observations = new Set<number>();
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<string, string>();
/** Creates whose insert rule a passing list check relied on, so it is no longer changed. */
readonly #placed = new Set<string>();
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) {
@@ -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("/")}`;
@@ -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<StateWritePlan, "body" | "observation">,
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<StateWritePlan, "merge" | "observation">,
@@ -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<string, StateView>;
}): Map<string, ReadonlyMap<string, number>> => {
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<string, ReadonlyMap<string, number>>();
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<string, number>();
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) =>
@@ -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<string>;
readonly sent: ReadonlySet<string>;
}[];
}
/** 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;