From 3b800a1076219563884ba8ff33dff066098e9afb Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 17:36:57 -0700 Subject: [PATCH] fix(environment): keep the largest sequence value in replay, and store update clocks by the kept read Replay now keeps the largest value a sequence took, as serving's commitWrite does, so a lower sent value no longer lowers the next number. Serving stores every update's clocks in the spelling of the read the plan keeps as observation.read; replay now does the same through readSpelledClocks, instead of adopting each update's own read, so a later update whose read spells its clock otherwise contradicts the plan. The plain clock branch of restampedFields, which no caller used any more, is gone. Co-Authored-By: Claude Opus 5.5 --- .../write-varied-calls/spec.md | 8 +-- docs/web-network-captures.md | 12 +++-- .../environmentStateVariedCalls.test.ts | 18 +++++++ .../environmentStateWriteCreate.ts | 33 +++++++----- .../environmentStateWriteEffects.ts | 30 ++++++++--- .../environmentStateWriteStamps.ts | 19 +------ tests/fixtures/environmentStateVariedCalls.ts | 52 +++++++++++++++++++ 7 files changed, 128 insertions(+), 44 deletions(-) diff --git a/docs/specs/agent-environments/write-varied-calls/spec.md b/docs/specs/agent-environments/write-varied-calls/spec.md index 015a104c3f..4458a29f52 100644 --- a/docs/specs/agent-environments/write-varied-calls/spec.md +++ b/docs/specs/agent-environments/write-varied-calls/spec.md @@ -28,15 +28,17 @@ the application breaks ties in. The skill now spaces creates. - **Update members.** A later update narrows the plan's required members to those every call sent. - **Numbered unless sent.** A member one create sent and stored as sent, and another left out and the application numbered one past the others, is a `sequence` with `sent`, in either order: serving stores the value a call sends, - else the next number, and a later call that sends it must have stored what it sent. + else the next number, and a later call that sends it must have stored what it sent. The next number follows the + largest value any call took, a deleted entity's included, in replay as in serving. - **Cleared by a PUT.** A field a `PUT` left out that the application cleared to an empty value (`""`, `null`, `false`, `[]` or `{}`) is that member's default (`body.defaults`) when every call that left it out stored that value, as its answer shows it or, where the answer leaves the member out, the read after it: serving stores it for an update that leaves the member out. Calls that left it out and stored it otherwise withhold the plan. - **Update clocks.** The state holds an update's clock as the first read of the entity after it spells the same instant at a coarser precision in the same zone and separator, as it already does for nested clocks: replay and serving store - that spelling by one rule, serving from the read the plan keeps as `observation.read`, while answers keep the update's - own. A create's clocks stay as answered, as serving makes them. + every update's clocks in the spelling of the read the plan keeps as `observation.read`, so a later update whose read + spells them otherwise contradicts the plan, while answers keep the update's own. A create's clocks stay as answered, + as serving makes them. - **Skill.** Recordings leave more than a second between two creates, as between a create and its first edit. Plans and views that need none of this are unchanged. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 762f57561b..4c2191e2bd 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -800,7 +800,8 @@ required, and an echoed member a call left out takes the value that call stored (`body.defaults`). A member one call sent and stored as sent, and another left out and the application numbered one past the others, takes the value a call sends, else that number (a `sequence` with `sent`), whichever call came first; -a later call that sends it must have stored what it sent. A member a create sent empty (`""`, `null`, `false`, `[]` or +a later call that sends it must have stored what it sent, and the next number +follows the largest value any call took, a deleted entity's included. A member a create sent empty (`""`, `null`, `false`, `[]` or `{}`), as a form sends a field left as it was, is not required: a call that leaves it out is applied as sending that value (`body.blank`), unless a call that left it out showed a default, which it then keeps. A call may send it as any @@ -874,10 +875,11 @@ with the query its first call sent, which serving applies to a call that leaves a parameter out and the catalog advertises as required. Every member every call sent is required, except by `PATCH` and `POST` updates and members a create sent empty. The state holds an update's clock as the first read of the entity after it spells the same instant at a -coarser precision in the same zone and separator: replay and serving store that -spelling by one rule, serving from the read the plan keeps as -`observation.read`, while answers keep the update's own; a create's clocks stay -as answered. A `PUT` default must agree with what every call that left the +coarser precision in the same zone and separator: replay and serving store every +update's clocks in the spelling of the read the plan keeps as +`observation.read`, so a later update whose read spells them otherwise +contradicts the plan, while answers keep the update's own; a create's clocks +stay as answered. A `PUT` default must agree with what every call that left the member out stored, as its answer shows it or, where the answer leaves it out, the read after the call. diff --git a/src/domain/analysisView/environmentStateVariedCalls.test.ts b/src/domain/analysisView/environmentStateVariedCalls.test.ts index a5d453c2db..9d7f494db9 100644 --- a/src/domain/analysisView/environmentStateVariedCalls.test.ts +++ b/src/domain/analysisView/environmentStateVariedCalls.test.ts @@ -7,11 +7,13 @@ import { import { CLEARED_THEN_OMITTED_KEPT_ROWS, DONE_CLOCK_ROWS, + DONE_MIXED_ROWS, DONE_ZONE_ROWS, OMITTED_KEPT_THEN_CLEARED_ROWS, FEWER_MEMBERS_ROWS, NUMBERED_UNLESS_SENT_ROWS, NUMBERED_THEN_SENT_ROWS, + SENT_THEN_DELETED_ROWS, KEPT_THEN_CLEARED_ROWS, CLEARED_THEN_KEPT_ROWS, PUT_CLEARS_ROWS, @@ -163,3 +165,19 @@ describe("writes whose calls send different members", () => { }); }); }); + +describe("writes replay holds as serving does", () => { + it("serves no create numbered below a sent value it deleted, as serving counts past it", () => { + // Serving keeps the largest order sent, 9, and would number the last create 10. + expect(planOf(SENT_THEN_DELETED_ROWS, "post_bundles")).toMatchObject({ + served: false, + }); + }); + + it("serves no update whose read spells its clock otherwise than the read the plan keeps", () => { + // Serving stores every update's clock as the first read spells it, to the millisecond. + expect(planOf(DONE_MIXED_ROWS, "patch_tasks_by_id")).toMatchObject({ + served: false, + }); + }); +}); diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 5dbe66a279..bc1491aa79 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -716,27 +716,35 @@ const fitClocks = (draft: WriteDraft, fields: JsonObject): void => { noteFixedWidths(draft.fixedWidth, fields); }; -/** Record the values a create's sequences took, which the next call follows. */ +/** Record the values a create's sequences took, which the next call follows; as serving does, the largest stays, so a lower sent value does not lower it. */ const takeSequences = ( draft: WriteDraft, created: Pick, ): void => { - for (const { rule, key } of sequencesOf(draft, created.collection)) { - const value = sequenceNumber( - ownMember(created.fields, rule.field), - ownMember(draft.observed, rule.field), + const take = (key: string, value: number | undefined) => { + if (value === undefined) return; + const taken = draft.sequences.get(key); + draft.sequences.set( + key, + taken === undefined ? value : Math.max(taken, value), + ); + }; + for (const { rule, key } of sequencesOf(draft, created.collection)) + take( + key, + sequenceNumber( + ownMember(created.fields, rule.field), + ownMember(draft.observed, rule.field), + ), ); - if (value !== undefined) draft.sequences.set(key, value); - } for (const rule of draft.plan.rules) { if (rule.rule !== "made-object") continue; const made = ownMember(created.fields, rule.field); for (const { member } of rule.counts) { - const value = isObject(made) - ? sequenceNumber(ownMember(made, member)) - : undefined; - if (value !== undefined) - draft.sequences.set(madeKey(rule.field, member), value); + take( + madeKey(rule.field, member), + isObject(made) ? sequenceNumber(ownMember(made, member)) : undefined, + ); } } }; @@ -977,7 +985,6 @@ export const applyCreate = ( itemReads(context, { type, idField, collection, id }), fields.value, draft.plan.rules, - false, ); const inserted = insertCreated(context, draft, entity(stored, id)); context.io.retain(collection.length * 2 + 16); diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index 3479ec1554..2d4f623afa 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -51,11 +51,13 @@ import { shownBefore, viewFields, type Observed, + type ObservationSearch, type ObservedRead, } from "./environmentStateWriteObserve.js"; import { plannedBody, readSpellsCoarser, + readSpelledClocks, restampedFields, } from "./environmentStateWriteStamps.js"; import { readClock } from "./environmentStateWriteValues.js"; @@ -526,6 +528,18 @@ const spelledReadBack = ( : undefined; }; +/** The object the plan's kept read-back shows, which serving spells every update's clocks by. */ +const keptRead = ( + search: ObservationSearch, + read: ObservedRead | undefined, +): JsonObject | undefined => + read === undefined + ? undefined + : search.withBody(read.ordinal, (body) => { + const object = body === undefined ? undefined : at(body, read.pointer); + return isObject(object) ? object : undefined; + }); + /** Build an update plan from its exemplar, or add what a later call showed. */ const buildUpdate = ( context: EffectContext, @@ -753,13 +767,17 @@ export const updateEntity = ( { before: entity.fields, after: after.object, sent: body, kept, read }, explained.unstamped, ); - // Response reproducibility is established before the read owner adopts a stored clock spelling. - const stored = restampedFields( - searchFrom(context, step), - itemReads(context, { type, idField, collection, id }), - written.value, + // Response reproducibility is established before the read owner adopts a stored clock spelling; serving spells every update's clocks as the plan's kept read-back does, so replay does too. + const search = searchFrom(context, step); + const stored = readSpelledClocks( draft.plan.rules, - true, + restampedFields( + search, + itemReads(context, { type, idField, collection, id }), + written.value, + draft.plan.rules, + ), + keptRead(search, draft.plan.observation?.read), ); context.io.retain( 64 + diff --git a/src/domain/analysisView/environmentStateWriteStamps.ts b/src/domain/analysisView/environmentStateWriteStamps.ts index a771010afb..5ca78757d1 100644 --- a/src/domain/analysisView/environmentStateWriteStamps.ts +++ b/src/domain/analysisView/environmentStateWriteStamps.ts @@ -11,7 +11,6 @@ import { } from "./environmentStateRules.js"; import { laterObservation, - respelledInstant, type ObservationSearch, } from "./environmentStateWriteObserve.js"; import { singleQueryValues } from "../recordedTransportUrl.js"; @@ -705,8 +704,7 @@ export const pinnedEchoes = ( }; /** - * A write's fields as stored: each nested clock as the first later read of the entity shows it restamped, and, for an - * update, each clock as that read spells the same instant at a coarser precision (as serving stores it), else as the + * A write's fields as stored: each nested clock as the first later read of the entity shows it restamped, else as the * call showed it. */ export const restampedFields = ( @@ -714,26 +712,13 @@ export const restampedFields = ( target: Parameters[1], shown: JsonObject, rules: readonly FieldRule[], - update: boolean, ): JsonObject => { const nested = rules.flatMap((rule) => rule.rule === "nested-clock" ? [rule] : [], ); - // Serving respells only an update's clocks, so a create keeps them as answered. - const clocks = update - ? rules.flatMap((rule) => (rule.rule === "clock" ? [rule.field] : [])) - : []; - if (nested.length === 0 && clocks.length === 0) return shown; + if (nested.length === 0) return shown; const later = laterObservation(search, target); const fields = { ...shown }; - for (const field of clocks) { - const read = ownMember(later?.object, field); - if ( - respelledInstant(ownMember(shown, field), read) && - readSpellsCoarser(ownMember(shown, field), read) - ) - setOwnMember(fields, field, read!); - } for (const rule of nested) { const read = storedRestamp( ownMember(shown, rule.field), diff --git a/tests/fixtures/environmentStateVariedCalls.ts b/tests/fixtures/environmentStateVariedCalls.ts index 086a9402c3..bbe357e34c 100644 --- a/tests/fixtures/environmentStateVariedCalls.ts +++ b/tests/fixtures/environmentStateVariedCalls.ts @@ -98,6 +98,58 @@ export const DONE_CLOCK_ROWS: readonly TimedCaptureRow[] = (() => { ]; })(); +/** Tasks marked done, each answer to the nanosecond: the first read spells `done_at` to the millisecond, the second to the second. */ +export const DONE_MIXED_ROWS: readonly TimedCaptureRow[] = DONE_CLOCK_ROWS.map( + (row, index) => + index === 4 && row[0] === "GET" + ? [ + row[0], + row[1], + { + ...(row[2] as Entity), + done_at: String((row[2] as Entity)["done_at"]).replace( + "Z", + ".001Z", + ), + }, + ] + : row, +); + +/** Bundles numbered unless an order is sent: 2 left out, 9 and 4 sent, the 9 deleted, then one left out the application numbers 5. */ +export const SENT_THEN_DELETED_ROWS: readonly TimedCaptureRow[] = (() => { + const bundle = (id: number, name: string, order: number): Entity => ({ + id, + name, + order, + }); + const seeds = [bundle(1, "Alpha", 0), bundle(2, "Bravo", 1)]; + const [charlie, delta, echo, foxtrot] = [ + bundle(3, "Charlie", 2), + bundle(4, "Delta", 9), + bundle(5, "Echo", 4), + bundle(6, "Foxtrot", 5), + ]; + return [ + ["GET", "/api/bundles", seeds], + ["GET", "/api/bundles/1", seeds[0]!], + ["POST", "/api/bundles", charlie, 201, { name: "Charlie" }], + ["GET", "/api/bundles/3", charlie], + ["GET", "/api/bundles", [...seeds, charlie]], + ["POST", "/api/bundles", delta, 201, { name: "Delta", order: 9 }], + ["GET", "/api/bundles/4", delta], + ["GET", "/api/bundles", [...seeds, charlie, delta]], + ["POST", "/api/bundles", echo, 201, { name: "Echo", order: 4 }], + ["GET", "/api/bundles/5", echo], + ["GET", "/api/bundles", [...seeds, charlie, delta, echo]], + ["DELETE", "/api/bundles/4", null, 204], + ["GET", "/api/bundles", [...seeds, charlie, echo]], + ["POST", "/api/bundles", foxtrot, 201, { name: "Foxtrot" }], + ["GET", "/api/bundles/6", foxtrot], + ["GET", "/api/bundles", [...seeds, charlie, echo, foxtrot]], + ]; +})(); + /** Tasks marked done: each answer stamps `done_at` to the millisecond in `Z`, the read after it to the second in `+00:00`. */ export const DONE_ZONE_ROWS: readonly TimedCaptureRow[] = (() => { const task = (id: number, title: string): Entity => ({