From 6300ec804af19705d5bccae5dcbcfafcc4684979 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 27 Oct 2026 10:46:37 -0700 Subject: [PATCH] feat(environment): serve writes whose calls send different members Recordings now vary what a type's two creates and updates send, and REA withheld writes where a later call left out what the first sent. An update plan's required members narrow to those every call sent; a member one create sent and another left for the application to number is a sequence that takes the value a call sends; a PUT's left-out member the application cleared becomes that member's default; and an update's clock is held as the read after it spells it. The skill spaces creates (skill 54). Co-Authored-By: Claude Opus 5.5 --- .../skills/reverse-engineer-anything/SKILL.md | 2 +- .../references/environments.md | 5 +- .../write-varied-calls/plan.md | 9 ++ .../write-varied-calls/spec.md | 50 ++++++++ .../write-varied-calls/tasks.md | 11 ++ .../write-varied-calls/verification.md | 64 ++++++++++ docs/web-network-captures.md | 14 +- .../environmentStateVariedCallsAnswer.test.ts | 65 ++++++++++ .../environmentStateVariedCalls.test.ts | 56 ++++++++ .../analysisView/environmentStateView.ts | 1 + .../environmentStateWriteApply.ts | 8 +- .../environmentStateWriteCalls.ts | 10 ++ .../environmentStateWriteCreateCalls.ts | 34 ++++- .../environmentStateWriteExplain.ts | 18 ++- .../environmentStateWriteRules.ts | 3 +- .../environmentStateWriteStamps.ts | 20 ++- src/generatedPackageMetadata.ts | 2 +- tests/fixtures/environmentStateVariedCalls.ts | 120 ++++++++++++++++++ 18 files changed, 475 insertions(+), 17 deletions(-) create mode 100644 docs/specs/agent-environments/write-varied-calls/plan.md create mode 100644 docs/specs/agent-environments/write-varied-calls/spec.md create mode 100644 docs/specs/agent-environments/write-varied-calls/tasks.md create mode 100644 docs/specs/agent-environments/write-varied-calls/verification.md create mode 100644 src/application/environment/environmentStateVariedCallsAnswer.test.ts create mode 100644 src/domain/analysisView/environmentStateVariedCalls.test.ts create mode 100644 tests/fixtures/environmentStateVariedCalls.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index cdda032e06..901195fb68 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "53" + version: "54" --- # REA diff --git a/.agents/skills/reverse-engineer-anything/references/environments.md b/.agents/skills/reverse-engineer-anything/references/environments.md index f8b3ca69be..b9c0dbe02e 100644 --- a/.agents/skills/reverse-engineer-anything/references/environments.md +++ b/.agents/skills/reverse-engineer-anything/references/environments.md @@ -153,8 +153,9 @@ client with it. Match `wait_for` locators to exactly one element. with varied free text. After each write, and before the next one, read the object back by id and then its list: create 1, read 1, list, create 2, read 2, list; never several writes before their reads. -4. Leave more than a second between a create and its first edit - (`wait_for_timeout` with `duration_ms: 1100`). +4. Leave more than a second between two creates and between a create and its + first edit (`wait_for_timeout` with `duration_ms: 1100`), so lists sorted + by creation time show them in order. 5. Update both objects and read each back, changing the fields a person would change (a name, an email address, a status, a due date) rather than only a description: a served update accepts only the members a recorded diff --git a/docs/specs/agent-environments/write-varied-calls/plan.md b/docs/specs/agent-environments/write-varied-calls/plan.md new file mode 100644 index 0000000000..93e9255730 --- /dev/null +++ b/docs/specs/agent-environments/write-varied-calls/plan.md @@ -0,0 +1,9 @@ +# Implementation plan + +1. **Update members.** `refinedBody` keeps only the required members a later update also sent. +2. **Numbered unless sent.** `sent` on sequence rules and their schema; the create merge adopts it for an echoed member + and a sequence from a call that left the member out; serving and replay take the sent value when there is one. +3. **Cleared by a PUT.** `explainUpdate` records a `PUT`'s left-out member cleared to an empty value as a default; + update refinement keeps defaults from later calls; `updatedFields` applies them to members a call leaves out. +4. **Update clocks.** `restampedFields` respells clocks from the first later read as it does nested clocks. +5. **Skill, tests and docs.** diff --git a/docs/specs/agent-environments/write-varied-calls/spec.md b/docs/specs/agent-environments/write-varied-calls/spec.md new file mode 100644 index 0000000000..c073546d4e --- /dev/null +++ b/docs/specs/agent-environments/write-varied-calls/spec.md @@ -0,0 +1,50 @@ +# Serve writes whose calls send different members + +Issue: https://github.com/morluto/rea/issues/4933 + +## Outcome + +A fresh recording round of seven applications, main against #7332, showed the recording skill now varies what the two +creates and two updates of a type send, as it asks. Where the second call left out a member the first sent, REA +withheld the write: + +- **Updates.** An update plan required every member its first call sent and never narrowed that, so EspoCRM's second + contact update, which changed only an email address, was refused in both recordings of the branch. +- **Members numbered when left out.** Linkding stores a bundle's `order` as sent, or one past the others when a call + leaves it out. One create showed the first and another the second, which the create merge took as a disagreement. + +The same recordings and the 130 earlier captures showed two more update gaps: + +- **Whole-object PUTs.** Paperless clears a tag's, correspondent's or document type's `owner` when a `PUT` leaves it + out, and Homebox an item's `assetId`, so those updates were `unexplained-change`. +- **Update clocks read at another precision.** Vikunja answers a task update with `done_at` to the nanosecond while its + reads spell it to the second, so the read after the update contradicted it. + +The round also found two creates of EspoCRM contacts in one second, which a list sorted newest first shows in an order +the application breaks ties in. The skill now spaces creates. + +## Behaviour + +- **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`: serving stores the value a call sends, else the next + number. +- **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`): serving stores it for an update that leaves the member out. +- **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, as it already does for nested clocks; answers keep the update's own spelling. +- **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. + +## Not covered + +- Vikunja numbers tasks per project. Creates into projects the recording did not list cannot be numbered, so its task + creates stay withheld. +- Linkding renumbers bundles' `order` after a delete, which no rule models. +- Homebox item creates expand sent tag ids into tag objects and pin `parentId`; they stay withheld. + +## Acceptance + +- No write tool of the recorded captures goes from served to withheld. +- The writes the diagnosis named are served where their recordings show nothing else against them. diff --git a/docs/specs/agent-environments/write-varied-calls/tasks.md b/docs/specs/agent-environments/write-varied-calls/tasks.md new file mode 100644 index 0000000000..446d670a0d --- /dev/null +++ b/docs/specs/agent-environments/write-varied-calls/tasks.md @@ -0,0 +1,11 @@ +# Tasks + +- [x] Narrow an update plan's required members to those every call sent. +- [x] Serve a member the value a call sends, else the next number, when calls showed both. +- [x] Learn and serve the value a `PUT` stores for a member it leaves out. +- [x] Hold an update's clock as the read after it spells it. +- [x] Ask recordings to space creates. +- [x] Add view, serving and unit tests. +- [x] Document the rules. +- [x] Measure the recorded captures. +- [x] Run static, formatting, documentation and test gates. diff --git a/docs/specs/agent-environments/write-varied-calls/verification.md b/docs/specs/agent-environments/write-varied-calls/verification.md new file mode 100644 index 0000000000..c7f9a1076a --- /dev/null +++ b/docs/specs/agent-environments/write-varied-calls/verification.md @@ -0,0 +1,64 @@ +# Verification + +Executed on macOS with Node v24.22.1 on this branch, on #7332 at `7d01a5d5` (main `5aa23c3d`), and on Ubuntu 24.04 VMs +with snap Chromium for the recording round. + +## Diagnosis + +Claude Code (`claude-sonnet-5-5`) built an environment of each of seven applications from the same prompt, once with +REA main `5aa23c3d` (skill 50) and once with #7332 at `7d01a5d5` (skill 53). Each bundle's write tasks ran with +`fireworks_ai/gpt-oss-120b` and `fireworks_ai/glm-5p3-flash`, which scored alike: + +| Application | Write tasks | Main | #7332 | +| ----------- | ----------- | ------- | ------- | +| Miniflux | 3 | 3/3 | 3/3 | +| Grafana | 2 | 2/2 | 2/2 | +| Vikunja | 3 + 2 | 0/3 0/2 | 1/3 2/2 | +| Paperless | 3 | 1/3 | 2/3 | +| Linkding | 2 | 0/2 | 2/2 | +| Homebox | 2 | 2/2 | 0/2 | +| EspoCRM | 3 | 1/3 | 0/3 | +| Total | 20 | 9/20 | 12/20 | + +Served writes were 14 and 25. Recorded again, Homebox and EspoCRM gave main 2/2 and 1/3 and #7332 2/2 and 3/3: the +first losses were the agents' runs, not REA. Built with both versions, none of the 18 recordings served fewer writes +with #7332 (44 and 53 in all). + +Replaying #7332's recordings with failing checks and refusals logged gave the causes this branch takes: EspoCRM's +second contact update left out `addressCity` and was refused for lacking a required member (both recordings); Linkding's +second bundle create left `order` to the application, which the merge took as disagreeing with the first create's +echo; Vikunja's task update stamped `done_at` to the nanosecond where the read after it showed seconds. Over the +earlier captures, 14 withheld updates were `PUT`s whose application cleared a member they left out (Paperless `owner`, +Homebox `assetId`). + +## Gates + +- `npm run -s check:fast`, `format:check`, `scan:todos`, `docs:check`, `verify:test-discovery`, `mcp-catalog:check` and + `jscpd`: passed. `npx knip` reports `staticFileUrlPath`, `ObjcClassImportBindingCoverage` and + `AndroidManifestComponentInventory`, as main `5aa23c3d` does. +- `npx vitest run --project domain --project services`: 11,694 passed, 8 failed (`appleApplication.test.ts`), which fail + the same way on main `5aa23c3d`. +- `npm run -s test:focused --` the environment build, export and serve CLI boundaries, the analysis-view CLI and MCP + boundaries and the environment serve MCP boundary: 80 passed. `outputSchemaProfile`, `toolSchemaValidity`, + `mcpSchemaValidation`, `outputSchemaSemanticConstraints` and `analysisViewMcp`: 54 passed; the compact catalog stays + under its frame. +- The seven new view and serving tests fail on #7332 at `7d01a5d5` and pass here. + +## Write plans over recorded captures + +The 130 captures #7332 measured and the round's 18 recordings, built by #7332 at `7d01a5d5` and by this branch: 694 +plans, 386 served and 398. No write goes from served to withheld and every view is unchanged. Newly served: + +- Paperless correspondent, document type and tag updates in four recordings, whose `PUT` clears `owner`. +- EspoCRM's contact update and Linkding's bundle create in #7332's recordings of the round. +- Grafana's folder update and delete in a fresh recording whose second update sent fewer members. +- Homebox's item update in the published capture, whose `PUT` clears `assetId`. + +Vikunja's task update reads back confirmed, but stays withheld: the task creates before it are not modelled (per-project +numbers), so their entities carry the creates' answer spellings into later reads. + +## Not verified + +- No recording round ran with this branch, so its write tasks are not measured. +- The skill's spacing of creates. +- Vikunja per-project task numbers, Linkding's renumbering after a delete and Homebox item creates, which stay withheld. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 3ab5b0e9e0..39366d76f1 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -796,7 +796,9 @@ past those deleted before the capture. Two `made-object` rules agree in their clocks and own members, and a count only the later call shows starts at the first object's value. Members either call sent are accepted, only those every call sent are required, and an echoed member a call left out takes the value that call stored -(`body.defaults`). A member a create sent empty (`""`, `null`, `false`, `[]` or +(`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`). 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 @@ -859,13 +861,17 @@ is unchanged) must be a clock, a nested clock, an increment, a slug, a fresh `un value, a `stamp` of the acting user, a value `derived` from the one member it stores nowhere, a `reference` to the entity a sent member names (kept by a call that names the entity the field already repeats), a `composed` string whose parts read members it stored as sent or fields it left as they were and spell the value before the call too, or for -an item action a `set` of a scalar to the value it showed, or the plan is +an item action a `set` of a scalar to the value it showed, or, for a `PUT`, a +member it left out that the application cleared to an empty value, which calls +that leave it out then store (`body.defaults`), or the plan is `unexplained-change`; an increment, slug, stamp, derived value or set must hold for every call of the tool, and an action is served only once a call found what it sets already set. A write is served only 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 the first call sent is required, except by `PATCH` and -`POST` updates and members a create sent empty. +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 too. A read on a line `query` answers is checked against the query evaluated over the replayed state. It covers the collection only when every parameter sorts or diff --git a/src/application/environment/environmentStateVariedCallsAnswer.test.ts b/src/application/environment/environmentStateVariedCallsAnswer.test.ts new file mode 100644 index 0000000000..50b792a6d1 --- /dev/null +++ b/src/application/environment/environmentStateVariedCallsAnswer.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + FEWER_MEMBERS_ROWS, + NUMBERED_UNLESS_SENT_ROWS, + PUT_CLEARS_ROWS, +} from "../../../tests/fixtures/environmentStateVariedCalls.js"; +import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js"; + +describe("served writes whose calls send different members", () => { + it("numbers a created bundle that leaves its order out, and keeps one a call sends", () => { + const bundles = servedFrom(NUMBERED_UNLESS_SENT_ROWS); + expect( + bundles.answer("post_bundles", { body: { name: "Echo" } }).body, + ).toMatchObject({ name: "Echo", order: 2 }); + expect( + bundles.answer("post_bundles", { body: { name: "Foxtrot", order: 9 } }) + .body, + ).toMatchObject({ + name: "Foxtrot", + order: 9, + }); + }); + + it("clears a member a PUT leaves out, as the application did, and keeps one it sends", () => { + const tags = servedFrom(PUT_CLEARS_ROWS); + expect( + tags.answer("put_tags_by_id", { id: "2", body: { name: "Receipts" } }) + .body, + ).toMatchObject({ + name: "Receipts", + owner: null, + }); + expect(tags.answer("get_tags_by_id", { id: "2" }).body).toMatchObject({ + owner: null, + }); + expect( + tags.answer("put_tags_by_id", { + id: "1", + body: { name: "Mail", owner: 3 }, + }).body, + ).toMatchObject({ + name: "Mail", + owner: 3, + }); + }); + + it("serves an update that sends only the member every recorded update sent", () => { + const contacts = servedFrom(FEWER_MEMBERS_ROWS); + expect( + contacts.answer("put_contacts_by_id", { + id: "1", + body: { email: "ada.f@example.test" }, + }).body, + ).toMatchObject({ name: "Ada Finch", email: "ada.f@example.test" }); + expect( + contacts.refusal("put_contacts_by_id", { + id: "2", + body: { city: "Kumasi" }, + }), + ).toMatchObject({ + message: expect.stringContaining("email"), + }); + }); +}); diff --git a/src/domain/analysisView/environmentStateVariedCalls.test.ts b/src/domain/analysisView/environmentStateVariedCalls.test.ts new file mode 100644 index 0000000000..fc7d0a153c --- /dev/null +++ b/src/domain/analysisView/environmentStateVariedCalls.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + timedCaptureEvidence, + type TimedCaptureRow, +} from "../../../tests/fixtures/analysisView.js"; +import { + DONE_CLOCK_ROWS, + FEWER_MEMBERS_ROWS, + NUMBERED_UNLESS_SENT_ROWS, + PUT_CLEARS_ROWS, +} from "../../../tests/fixtures/environmentStateVariedCalls.js"; +import { environmentStateViewOf } from "../../../tests/fixtures/environmentStateWrites.js"; + +const planOf = (rows: readonly TimedCaptureRow[], tool: string) => { + const plan = environmentStateViewOf(timedCaptureEvidence(rows)).writes.find( + (candidate) => candidate.tool === tool, + ); + if (plan === undefined) throw new Error(`No plan for ${tool}`); + return plan; +}; + +describe("writes whose calls send different members", () => { + it("serves an update a later call sends fewer members to, requiring only those every call sent", () => { + const plan = planOf(FEWER_MEMBERS_ROWS, "put_contacts_by_id"); + expect(plan).toMatchObject({ served: true }); + expect(plan.body?.required).toEqual(["email"]); + expect(plan.body?.accepted).toEqual( + expect.arrayContaining(["name", "city", "email"]), + ); + }); + + it("serves a create whose member one call sent and another left for the application to number", () => { + const plan = planOf(NUMBERED_UNLESS_SENT_ROWS, "post_bundles"); + expect(plan).toMatchObject({ served: true }); + expect(plan.rules).toContainEqual({ + field: "order", + rule: "sequence", + scope: "collection", + sent: true, + }); + expect(plan.body?.required).toEqual(["name"]); + }); + + it("confirms an update whose read shows the clock it stamped to the second", () => { + expect(planOf(DONE_CLOCK_ROWS, "patch_tasks_by_id")).toMatchObject({ + served: true, + }); + }); + + it("serves a PUT that clears a member it left out, as a default", () => { + const plan = planOf(PUT_CLEARS_ROWS, "put_tags_by_id"); + expect(plan).toMatchObject({ served: true }); + expect(plan.body?.defaults).toEqual([{ member: "owner", value: null }]); + }); +}); diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index aa98efe792..695d5ee683 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -246,6 +246,7 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [ rule: z.literal("sequence"), scope: z.enum(["collection", "type"]), start: z.number().int().exactOptional(), + sent: z.literal(true).exactOptional(), }), z.strictObject({ ...ruled, rule: z.literal("unique") }), z.strictObject({ diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 05a93adefe..dfdb35254a 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -229,8 +229,11 @@ const ruledValue = ( switch (rule.rule) { case "reference": return referencedValue(rule, body, referenced); - case "identity": case "sequence": + return rule.sent === true && Object.hasOwn(body, rule.field) + ? ok(body[rule.field]!) + : generate(rule); + case "identity": case "unique": case "renewed": case "clock": @@ -489,6 +492,9 @@ export const updatedFields = ( setOwnMember(written, rule.field, value.value); } } + // A member the call leaves out takes the default a whole-object update showed. + for (const { member, value } of plan.body.defaults ?? []) + if (!Object.hasOwn(sent, member)) setOwnMember(written, member, value); const unspelled = derive( plan.rules.filter( (rule) => rule.rule !== "slug" || Object.hasOwn(sent, rule.from), diff --git a/src/domain/analysisView/environmentStateWriteCalls.ts b/src/domain/analysisView/environmentStateWriteCalls.ts index a649471573..b7dd6e4783 100644 --- a/src/domain/analysisView/environmentStateWriteCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCalls.ts @@ -228,11 +228,21 @@ export const refinedBody = ( const accepted = later.accepted.filter( (member) => !body.accepted.includes(member), ); + const held = body.defaults ?? []; + const defaults = [ + ...held, + ...(later.defaults ?? []).filter( + (entry) => !held.some((kept) => kept.member === entry.member), + ), + ]; return { ...body, + ...(defaults.length === 0 ? {} : { defaults }), // A later call may be the first to show the body repeating the path's id. identity: body.identity ?? later.identity, accepted: [...body.accepted, ...accepted], + // A member is required only when every call sent it. + required: body.required.filter((member) => later.required.includes(member)), fixed: [ ...body.fixed, ...later.fixed.filter((entry) => !fixed.has(entry.member)), diff --git a/src/domain/analysisView/environmentStateWriteCreateCalls.ts b/src/domain/analysisView/environmentStateWriteCreateCalls.ts index 52903710e9..8fc3667f35 100644 --- a/src/domain/analysisView/environmentStateWriteCreateCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCreateCalls.ts @@ -322,6 +322,31 @@ const shownAlone = ( return sent ? undefined : { rule: own }; }; +/** A member one call sent and stored as sent, and another left out and the application numbered: the value a call sends, else the next number. */ +const sentOrNumbered = ( + before: FieldRule, + after: FieldRule, + field: string, + calls: FieldCalls, + agree: (held: FieldRule) => FieldRule | undefined, +): FieldRule | undefined => { + const laterSent = Object.hasOwn(calls.later.body, field); + if (before.rule === "echo") + return after.rule === "sequence" && !laterSent + ? { ...after, sent: true } + : undefined; + if (before.rule !== "sequence") return undefined; + if (after.rule === "echo") + return before.sent === true || !calls.sentBefore.has(field) + ? { ...before, sent: true } + : undefined; + if (before.sent !== true || after.rule !== "sequence" || laterSent) + return undefined; + const { sent: _sent, ...numbered } = before; + const merged = agree(numbered); + return merged?.rule === "sequence" ? { ...merged, sent: true } : undefined; +}; + /** One field's rule in the merged plan, and the default it gives a member; undefined when the calls disagree on it. */ const mergedField = ( field: string, @@ -334,13 +359,18 @@ const mergedField = ( if (!inExemplar || !Object.hasOwn(later.entity, field)) return shownAlone(inExemplar, calls); if (before !== undefined && after !== undefined) { - const rule = agreeing(before, after, { + const values = { field, later: later.entity[field], earlier: calls.earlier, exemplar, laterEntity: later.entity, - }); + }; + const rule = + agreeing(before, after, values) ?? + sentOrNumbered(before, after, field, calls, (held) => + agreeing(held, after, values), + ); return rule === undefined ? undefined : { rule }; } // The plan echoes a member this call left out: what it stored there is the member's default. diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index 6b2563724e..bc8aa8ad25 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -522,6 +522,8 @@ export const explainUpdate = ( const rules: FieldRule[] = []; const echoed = new Set(); const unexplained: string[] = []; + // A whole-object PUT clears a member it leaves out: that value is the member's default. + const defaults: { readonly member: string; readonly value: JsonValue }[] = []; // A member the application replaces with a value it generates is accepted with any value. const generated = new Set(); let identity: string | null = null; @@ -593,8 +595,14 @@ export const explainUpdate = ( body, knownEntities, ); - if (rule === undefined) unexplained.push(field); - else rules.push(rule); + if (rule !== undefined) rules.push(rule); + else if ( + evidence.method === "PUT" && + isBlankValue(ownMember(evidence.after, field)) && + !isBlankValue(ownMember(evidence.before, field)) + ) + defaults.push({ member: field, value: evidence.after[field]! }); + else unexplained.push(field); } rules.push(...pairedStamps(evidence, rules)); // A member a reference reads names an entity, which any held one may be. @@ -614,7 +622,11 @@ export const explainUpdate = ( ); return { rules, - body: { ...shaped, identity }, + body: { + ...shaped, + identity, + ...(defaults.length === 0 ? {} : { defaults }), + }, unexplained, unstamped: sameSecondClocks(evidence), }; diff --git a/src/domain/analysisView/environmentStateWriteRules.ts b/src/domain/analysisView/environmentStateWriteRules.ts index 3378f67be8..7da1aeb34b 100644 --- a/src/domain/analysisView/environmentStateWriteRules.ts +++ b/src/domain/analysisView/environmentStateWriteRules.ts @@ -53,12 +53,13 @@ export type GeneratedRule = readonly rule: "identity"; readonly generator: "sequence" | "uuid" | "token"; } - /** One past the largest value its scope holds, and never below `start`, a value the first create took above them. */ + /** One past the largest value its scope holds, and never below `start`, a value the first create took above them; with `sent`, the value a call sends instead. */ | { readonly field: string; readonly rule: "sequence"; readonly scope: "collection" | "type"; readonly start?: number; + readonly sent?: true; } | { readonly field: string; readonly rule: "unique" } /** The value kept with each UUID at the pointers (`*` for any array index) replaced by a fresh one, as rewritten child rows are. */ diff --git a/src/domain/analysisView/environmentStateWriteStamps.ts b/src/domain/analysisView/environmentStateWriteStamps.ts index 963ab5c4df..397849253e 100644 --- a/src/domain/analysisView/environmentStateWriteStamps.ts +++ b/src/domain/analysisView/environmentStateWriteStamps.ts @@ -11,6 +11,7 @@ import { } from "./environmentStateRules.js"; import { laterObservation, + respelledInstant, type ObservationSearch, } from "./environmentStateWriteObserve.js"; import { singleQueryValues } from "../recordedTransportUrl.js"; @@ -649,7 +650,10 @@ export const pinnedEchoes = ( ); }; -/** An update's fields as stored: each nested clock as the first later read of the entity shows it restamped, else as the call showed it. */ +/** + * An update's fields as stored: each nested clock as the first later read of the entity shows it restamped, and each + * clock as that read spells the same instant at a coarser precision, else as the call showed it. + */ export const restampedFields = ( search: ObservationSearch, target: Parameters[1], @@ -659,9 +663,21 @@ export const restampedFields = ( const nested = rules.flatMap((rule) => rule.rule === "nested-clock" ? [rule] : [], ); - if (nested.length === 0) return shown; + const clocks = rules.flatMap((rule) => + rule.rule === "clock" ? [rule.field] : [], + ); + if (nested.length === 0 && clocks.length === 0) return shown; const later = laterObservation(search, target); const fields = { ...shown }; + for (const field of clocks) { + const read = ownMember(later?.object, field); + if ( + read !== undefined && + !same(read, ownMember(shown, field)) && + respelledInstant(ownMember(shown, field), read) + ) + setOwnMember(fields, field, read); + } for (const rule of nested) { const read = storedRestamp( ownMember(shown, rule.field), diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index 25bb35bb64..5372dbba8b 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -6,5 +6,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "53", + skillVersion: "54", } as const; diff --git a/tests/fixtures/environmentStateVariedCalls.ts b/tests/fixtures/environmentStateVariedCalls.ts new file mode 100644 index 0000000000..2d4577ebb3 --- /dev/null +++ b/tests/fixtures/environmentStateVariedCalls.ts @@ -0,0 +1,120 @@ +import type { TimedCaptureRow } from "./analysisView.js"; +import { bookClock } from "./environmentStateWrites.js"; +import type { JsonValue } from "../../src/domain/jsonValue.js"; + +type Entity = { readonly [key: string]: JsonValue }; + +/** Two contact updates, the second sending only the email the first sent with a name and a city. */ +export const FEWER_MEMBERS_ROWS: readonly TimedCaptureRow[] = (() => { + const contact = ( + id: number, + name: string, + city: string, + email: string, + ): Entity => ({ id, name, city, email }); + const [ada, mina] = [ + contact(1, "Ada Finch", "Porto", "ada@example.test"), + contact(2, "Mina Okafor", "Accra", "mina@example.test"), + ]; + const adaMoved = contact( + 1, + "Ada Finch-Lowe", + "Lisbon", + "ada.lowe@example.test", + ); + const minaMail = { ...mina, email: "mina.o@example.test" }; + return [ + ["GET", "/api/contacts", [ada, mina]], + ["GET", "/api/contacts/1", ada], + ["GET", "/api/contacts/2", mina], + [ + "PUT", + "/api/contacts/1", + adaMoved, + 200, + { + name: "Ada Finch-Lowe", + city: "Lisbon", + email: "ada.lowe@example.test", + }, + ], + ["GET", "/api/contacts/1", adaMoved], + ["PUT", "/api/contacts/2", minaMail, 200, { email: "mina.o@example.test" }], + ["GET", "/api/contacts/2", minaMail], + ["GET", "/api/contacts", [adaMoved, minaMail]], + ]; +})(); + +/** Bundle creates: the first sends its order, the second leaves it to the application, which numbers it next. */ +export const NUMBERED_UNLESS_SENT_ROWS: readonly TimedCaptureRow[] = (() => { + const bundle = (id: number, name: string, order: number): Entity => ({ + id, + name, + order, + }); + const [alpha, bravo] = [bundle(1, "Alpha", 0), bundle(2, "Bravo", 1)]; + const [charlie, delta] = [bundle(3, "Charlie", 5), bundle(4, "Delta", 6)]; + return [ + ["GET", "/api/bundles", [alpha, bravo]], + ["GET", "/api/bundles/1", alpha], + ["GET", "/api/bundles/2", bravo], + ["POST", "/api/bundles", charlie, 201, { name: "Charlie", order: 5 }], + ["GET", "/api/bundles/3", charlie], + ["GET", "/api/bundles", [alpha, bravo, charlie]], + ["POST", "/api/bundles", delta, 201, { name: "Delta" }], + ["GET", "/api/bundles/4", delta], + ["GET", "/api/bundles", [alpha, bravo, charlie, delta]], + ]; +})(); + +/** Tasks marked done: each answer stamps `done_at` to the nanosecond, the read after it to the second. */ +export const DONE_CLOCK_ROWS: readonly TimedCaptureRow[] = (() => { + const task = (id: number, title: string): Entity => ({ + id, + title, + done: false, + done_at: "0001-01-01T00:00:00Z", + }); + const [trim, oil] = [task(1, "Trim the wicks"), task(2, "Order lamp oil")]; + const done = (entity: Entity, at: number, fraction: string): Entity => ({ + ...entity, + done: true, + done_at: bookClock(at).replace("Z", fraction), + }); + return [ + ["GET", "/api/tasks", [trim, oil]], + ["GET", "/api/tasks/1", trim], + ["GET", "/api/tasks/2", oil], + [ + "PATCH", + "/api/tasks/1", + done(trim, 3, ".001234567Z"), + 200, + { done: true }, + ], + ["GET", "/api/tasks/1", done(trim, 3, "Z")], + ["PATCH", "/api/tasks/2", done(oil, 5, ".002345678Z"), 200, { done: true }], + ["GET", "/api/tasks/2", done(oil, 5, "Z")], + ]; +})(); + +/** Tag PUTs: one leaves the owner out and the application clears it, the other sends it and keeps it. */ +export const PUT_CLEARS_ROWS: readonly TimedCaptureRow[] = (() => { + const tag = (id: number, name: string, owner: number | null): Entity => ({ + id, + name, + owner, + }); + const [inbox, bills] = [tag(1, "Inbox", 3), tag(2, "Bills", 3)]; + const [triage, paid] = [tag(1, "Triage", null), tag(2, "Paid", 3)]; + return [ + ["GET", "/api/tags", [inbox, bills]], + ["GET", "/api/tags/1", inbox], + ["GET", "/api/tags/2", bills], + ["PUT", "/api/tags/1", triage, 200, { name: "Triage" }], + ["GET", "/api/tags/1", triage], + ["PUT", "/api/tags/2", paid, 200, { name: "Paid", owner: 3 }], + ["GET", "/api/tags/2", paid], + ["GET", "/api/tags", [triage, paid]], + ]; +})();