diff --git a/docs/specs/agent-environments/state-writes/spec.md b/docs/specs/agent-environments/state-writes/spec.md index d0350e41f2..08ed9d4939 100644 --- a/docs/specs/agent-environments/state-writes/spec.md +++ b/docs/specs/agent-environments/state-writes/spec.md @@ -193,11 +193,12 @@ refuses to answer with. **Attribution.** - A check exercises the write tools that changed its entity or collection - since the previous check of it, less those a later write replaced: a create - or delete replaces an entity's earlier writes, an update the earlier updates - whose every changed field, sent or derived, it overwrites (an increment adds - to its field rather than overwriting it), and a write left with no changed - entity in a collection no longer counts there. A pass confirms them and a mismatch + since the previous check of it, less those whose effect later writes erased. + An update's effect is the fields it sent or changed, derived ones included. + A later update overwrites the fields it sets (an increment adds to its field + instead), a recorded outcome every field, and a create or delete the whole + entity. A write with nothing left on an entity no longer counts there, nor in + a collection where no entity it changed keeps any of its effect. A pass confirms them and a mismatch contradicts them. - A mismatch after an unmodelled write that followed those writes is checked only on the members they sent: such a write may explain the rest. @@ -267,8 +268,9 @@ recording instead. A create in a collection held under more than one spelling (`acme1` and `%61cme1`) goes to the one the call spells verbatim, as item calls choose their -entity; a collection keeps its spelling after its last entity is deleted, until -a reset or load starts from the spellings of the state it installs. `data/add` refuses a document that `data/get` would write back past the +entity; a collection keeps its spelling after its last entity is deleted. A +reset or load knows the spellings of the Evidence's state and of the state it +installs, never those of a state it replaced. `data/add` refuses a document that `data/get` would write back past the 32 MiB intake limit once it adds the members a document may omit, such as `types` and the state Evidence id. diff --git a/docs/specs/agent-environments/state-writes/verification.md b/docs/specs/agent-environments/state-writes/verification.md index 1aaa975381..871fbff47a 100644 --- a/docs/specs/agent-environments/state-writes/verification.md +++ b/docs/specs/agent-environments/state-writes/verification.md @@ -244,3 +244,12 @@ forgets the collection spellings of the state it replaced. Both new tests fail on the previous head. The verdicts over the 50 captures are unchanged plan by plan, the in-process smoke applies all 31 served write tools, and the gates passed again. + +An eighth round settled three more. A later write erases the fields of an +earlier effect it overwrites, so two writes that together overwrite all of an +update's changes leave it nothing to be confirmed by. A recorded outcome erases +what earlier writes changed on its entity. A load keeps the Evidence's own +collection spellings, so a create in a collection the loaded state does not +hold keeps its recorded spelling. Each new test fails on the previous head. The +verdicts over the 50 captures are unchanged plan by plan, the smoke applies all +31 served write tools, and the gates passed again. diff --git a/src/application/environment/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts index dce3dae2a9..10f4c0b80b 100644 --- a/src/application/environment/environmentStateStore.ts +++ b/src/application/environment/environmentStateStore.ts @@ -65,6 +65,8 @@ export class EnvironmentStateStore { #started: readonly StateEntity[] = []; /** Every collection spelling a type's entities have been held under since the last reset or load, kept when a collection empties. */ readonly #knownCollections = new Map(); + /** The collections of the Evidence's own state, which stay known after any load. */ + #evidenceCollections: ReadonlyMap = new Map(); constructor(readonly state: EnvironmentState) { this.#types = new Map( @@ -80,6 +82,7 @@ export class EnvironmentStateStore { /** Restore the initial state the Evidence holds; the clock and counter start again. */ reset(): void { this.#install(this.state, this.#initialBytes); + this.#evidenceCollections = this.#typeCollections; this.#restart(); } @@ -133,10 +136,19 @@ export class EnvironmentStateStore { this.#writes = 0; this.#sequences.clear(); this.#started = this.#content.entities; - // A reset or load starts as a fresh store would, without the spellings of the state it replaced. + // A reset or load knows the Evidence's spellings and its own, never those of a state it replaced. this.#knownCollections.clear(); - for (const [type, named] of this.#typeCollections) - this.#knownCollections.set(type, [...named]); + this.#remember(this.#evidenceCollections); + this.#remember(this.#typeCollections); + } + + #remember(named: ReadonlyMap): void { + for (const [type, collections] of named) { + const known = this.#knownCollections.get(type) ?? []; + for (const collection of collections) + if (!known.includes(collection)) known.push(collection); + this.#knownCollections.set(type, known); + } } /** The entities of a type the state held when it was last reset or loaded. */ @@ -343,12 +355,7 @@ export class EnvironmentStateStore { this.#entities = entities; this.#collections = collections; this.#typeCollections = typeCollections; - for (const [type, named] of typeCollections) { - const known = this.#knownCollections.get(type) ?? []; - for (const collection of named) - if (!known.includes(collection)) known.push(collection); - this.#knownCollections.set(type, known); - } + this.#remember(typeCollections); this.#documents = new Map( content.documents.map((document) => [document.tool, document]), ); diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index 58df89e182..382c375d06 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -550,6 +550,23 @@ describe("values the state started with", () => { expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books"); }); + it("creates in the Evidence's spelling of a collection a loaded state does not hold", () => { + const { books, create } = twoOrgs("my%20org1", "my org1"); + const { rea_state: held } = books.store.snapshot(); + expect( + books.store.replace({ + rea_state: { + version: held.version, + entities: held.entities.filter( + (entity) => !entity.collection.includes("%20"), + ), + documents: held.documents, + }, + }).ok, + ).toBe(true); + expect(create("my%20org1", "Epsilon")).toBe("/api/orgs/my%20org1/books"); + }); + it("forgets the spellings of a loaded state once reset", () => { const { books, create } = twoOrgs("%61cme1", "acme1"); const { rea_state: held } = books.store.snapshot(); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts index a5701732b9..220d4295f6 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts @@ -406,12 +406,15 @@ describe("reads the served answer no longer matches", () => { ]; expect(gated(rows, "post_books")).toMatchObject({ served: false }); }); +}); + +describe("updates whose effect later writes erase", () => { + const versioned = (entry: Book, version: number): Book => ({ + ...entry, + version, + }); it("keeps an update confirmed when a later one rewrites only part of what it changed", () => { - const versioned = (entry: Book, version: number): Book => ({ - ...entry, - version, - }); const again = versioned(renamedBook(FIRST_BOOK, "Again Book", 4), 2); const rows: TimedCaptureRow[] = [ [ @@ -437,4 +440,90 @@ describe("reads the served answer no longer matches", () => { checked_record_ordinals: [5], }); }); + + it("confirms no update whose changes later writes overwrote between them", () => { + const renamed = versioned(renamedBook(FIRST_BOOK, "Again Book", 4), 2); + const final = versioned(renamedBook(FIRST_BOOK, "Again Book", 5), 9); + const rows: TimedCaptureRow[] = [ + [ + "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", + versioned(RENAMED, 2), + 200, + { name: "Renamed Book" }, + ], + ["PUT", "/api/books/1", renamed, 200, { name: "Again Book" }], + ["POST", "/api/books/1", final, 200, { version: 9 }], + ["GET", "/api/books/1", final], + ]; + // The PUT overwrote the name, slug and clock, the POST the version: nothing of the PATCH is left to read. + expect(gated(rows, "patch_books_by_id")).toMatchObject({ + served: false, + checked_record_ordinals: [], + }); + expect(gated(rows, "put_books_by_id")).toMatchObject({ + checked_record_ordinals: [6], + }); + }); + + it("confirms no update whose changes a recorded outcome replaced", () => { + const described = ( + entry: Book, + name: string, + at: number, + text: string, + ): Book => ({ + ...renamedBook(entry, name, at), + description: text, + }); + const final = described(FIRST_BOOK, "Other Book", 7, "z"); + const rows: TimedCaptureRow[] = [ + [ + "GET", + "/api/books", + [ + { ...FIRST_BOOK, description: "d" }, + { ...SECOND_BOOK, description: "d" }, + ], + ], + ["GET", "/api/books/1", { ...FIRST_BOOK, description: "d" }], + ["GET", "/api/books/2", { ...SECOND_BOOK, description: "d" }], + [ + "PUT", + "/api/books/2", + described(SECOND_BOOK, "Second Book", 3, "x"), + 200, + { description: "x" }, + ], + [ + "PATCH", + "/api/books/1", + described(FIRST_BOOK, "Renamed Book", 4, "d"), + 200, + { name: "Renamed Book" }, + ], + // Without a body the PUT is not modelled: its recorded outcome becomes the book's values. + ["PUT", "/api/books/1", described(FIRST_BOOK, "Other Book", 5, "d")], + [ + "PATCH", + "/api/books/1", + described(FIRST_BOOK, "Other Book", 6, "y"), + 200, + { description: "y" }, + ], + ["PUT", "/api/books/1", final, 200, { description: "z" }], + ["GET", "/api/books/1", final], + ]; + expect(gated(rows, "patch_books_by_id")).toMatchObject({ + served: false, + checked_record_ordinals: [], + }); + }); }); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 8fb4f2a2e7..5a70be2ccc 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -231,6 +231,8 @@ export class EnvironmentStateWriteSimulation { ); if (result.kind === "applied" && result.entity !== undefined) this.#effects.supersede(this.#touched, write.tool, result); + if (result.kind === "observed") + this.#effects.observed(this.#touched, result.targets); for (const target of result.targets) { // A recorded outcome replaces the entity's values, not its collection's membership. if (result.kind === "observed") { diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index d56689671f..844a84076c 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -129,18 +129,27 @@ export const servedLine = ( /** Modelled writes per target since its last check, with the fields each wrote (null: all). */ export type TouchedTargets = Map>; -/** The fields a write changed on an entity: a create or delete all of them (null), an update those it sent or derived. */ +/** The fields of a write's effect on an entity no later write has overwritten: all of them for a create or delete (null). */ type ChangedFields = readonly string[] | null; -/** Whether a later write replaces an earlier one's effect: it changes the whole entity, or overwrites every field the earlier changed (an increment adds to its field instead). */ -const replaces = ( - write: { readonly whole: boolean; readonly overwrites: ReadonlySet }, - earlier: ChangedFields | undefined, -): boolean => - earlier !== undefined && - (write.whole || - (earlier !== null && - earlier.every((field) => write.overwrites.has(field)))); +/** What a later write overwrites: the whole entity, the fields it sets, or every field (a recorded outcome). */ +interface Overwrite { + readonly whole: boolean; + readonly fields: ReadonlySet | "all"; +} + +/** What an earlier effect keeps after a write: nothing once every field it holds is overwritten; an update leaves a create's or delete's whole effect. */ +const remaining = ( + claim: ChangedFields, + write: Overwrite, +): ChangedFields | undefined => { + if (write.whole) return undefined; + if (claim === null) return null; + const { fields } = write; + if (fields === "all") return undefined; + const left = claim.filter((field) => !fields.has(field)); + return left.length > 0 ? left : undefined; +}; /** Add a write's changed fields under a key: all of them once any write there changed the whole entity. */ const record = ( @@ -159,8 +168,9 @@ const record = ( }; /** - * What each write changed since the targets it touched were last checked, so a later write that replaces its effect - * drops it: from the entity's target, and from its collection's once no entity it changed there is left. + * What each write changed since the targets it touched were last checked, less what later writes overwrote, so a + * write whose effect is gone drops out: from the entity's target, and from its collection's once no entity it changed + * there keeps any of it. */ export class WriteEffects { /** Per entity target, the fields each tool changed there. */ @@ -177,6 +187,7 @@ export class WriteEffects { this.#members.delete(key); } + /** A write's effect replaces what earlier writes left of theirs, then joins them. */ supersede( touched: TouchedTargets, writer: string, @@ -191,34 +202,73 @@ export class WriteEffects { ): void { const [entity, collection] = effect.targets as [string, string?]; const changed = effect.changed?.fields ?? effect.fields ?? []; - const write = { - whole: effect.fields === undefined, - overwrites: new Set( - changed.filter((field) => !effect.changed?.accumulated.includes(field)), - ), - }; - const own = this.#changed.get(entity) ?? new Map(); - for (const [tool, fields] of own) - if (tool !== writer && replaces(write, fields)) { - own.delete(tool); - touched.get(entity)?.delete(tool); - } - this.#changed.set(entity, record(own, writer, write.whole, changed)); + const whole = effect.fields === undefined; + this.#overwrite( + touched, + { entity, collection, writer }, + { + whole, + fields: new Set( + changed.filter( + (field) => !effect.changed?.accumulated.includes(field), + ), + ), + }, + ); + this.#changed.set( + entity, + record(this.#changed.get(entity) ?? new Map(), writer, whole, changed), + ); if (collection === undefined) return; const byTool = this.#members.get(collection) ?? new Map(); - for (const [tool, entities] of byTool) { - if (tool === writer || !replaces(write, entities.get(entity))) continue; - entities.delete(entity); - if (entities.size > 0) continue; - byTool.delete(tool); - touched.get(collection)?.delete(tool); - } byTool.set( writer, - record(byTool.get(writer) ?? new Map(), entity, write.whole, changed), + record(byTool.get(writer) ?? new Map(), entity, whole, changed), ); this.#members.set(collection, byTool); } + + /** A recorded outcome is the entity's new baseline: what earlier writes changed on it is gone, its membership is not. */ + observed(touched: TouchedTargets, targets: readonly string[]): void { + const [entity, collection] = targets as [string, string?]; + this.#overwrite( + touched, + { entity, collection, writer: undefined }, + { whole: false, fields: "all" }, + ); + } + + #overwrite( + touched: TouchedTargets, + at: { + readonly entity: string; + readonly collection: string | undefined; + readonly writer: string | undefined; + }, + write: Overwrite, + ): void { + const own = this.#changed.get(at.entity); + for (const [tool, claim] of own ?? []) { + const left = tool === at.writer ? claim : remaining(claim, write); + if (left !== undefined) own!.set(tool, left); + else { + own!.delete(tool); + touched.get(at.entity)?.delete(tool); + } + } + if (at.collection === undefined) return; + const byTool = this.#members.get(at.collection); + for (const [tool, entities] of byTool ?? []) { + const claim = entities.get(at.entity); + if (tool === at.writer || claim === undefined) continue; + const left = remaining(claim, write); + if (left !== undefined) entities.set(at.entity, left); + else entities.delete(at.entity); + if (entities.size > 0) continue; + byTool!.delete(tool); + touched.get(at.collection)?.delete(tool); + } + } } export const entityTarget = (type: string, collection: string, id: string) =>