From 938210244311e8c8f5e7618aebb497ca9723e16e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 26 Oct 2026 16:51:12 -0700 Subject: [PATCH] fix(environment): learn the soft-delete flag in the state view and serve by it Extraction now records the member later item reads set true on a record the application soft-deleted, and the item view carries it as `deleted_flag`. Serving reads that fact instead of scanning recorded bodies itself, so one owner decides what a soft delete looks like. Co-Authored-By: Claude Opus 5.5 --- docs/agent-environments.md | 11 ++-- .../write-later-reads/spec.md | 5 +- docs/web-network-captures.md | 3 +- .../environment/environmentStateAnswer.ts | 51 +++---------------- .../EnvironmentStateExtraction.ts | 25 ++++++--- .../analysisView/environmentStateGate.ts | 5 ++ .../environmentStateLaterReads.test.ts | 10 ++++ .../analysisView/environmentStateRules.ts | 6 ++- .../analysisView/environmentStateView.ts | 7 +++ 9 files changed, 63 insertions(+), 60 deletions(-) diff --git a/docs/agent-environments.md b/docs/agent-environments.md index b6d763c77f..b8bfdd5c58 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -765,11 +765,12 @@ sending a `derived` rule's member to an entity that does not hold the value the recorded call found, a nested clock on an entity holding no such object, and a rendered answer missing a field the recorded response carried, and an update or delete of an entity the state does not hold answers the recorded 400, 404 or 410 of the tool or of an item view of the type, if -any. A served delete of a type whose recorded item reads answer a deleted record -marked deleted (`deleted`, `isDeleted` or `is_deleted` `true`, as EspoCRM does) -keeps the entity until `data/reset` or `data/add`, within the size `data/add` -loads, so a read of it by id answers its last fields with that flag `true`. A -refused call leaves the state unchanged. Values a write generates are +any. A served delete of a type whose item view has a `deleted_flag` (the +member, `deleted`, `isDeleted` or `is_deleted`, its later reads set `true` on a +record the application soft-deleted, as EspoCRM does) keeps the entity until +`data/reset` or `data/add`, within the size `data/add` loads, so a read of it by +id answers its last fields with that flag `true`. A refused call leaves the +state unchanged. Values a write generates are deterministic: the clock starts at `write_clock` and advances one second per served write, sequences take one past both the largest held value and the largest they generated, and UUIDs and tokens derive from the state Evidence diff --git a/docs/specs/agent-environments/write-later-reads/spec.md b/docs/specs/agent-environments/write-later-reads/spec.md index 8259df2f76..7653efbc0d 100644 --- a/docs/specs/agent-environments/write-later-reads/spec.md +++ b/docs/specs/agent-environments/write-later-reads/spec.md @@ -19,8 +19,9 @@ each write back: - **Soft deletes.** An item read whose object is marked deleted (`deleted`, `isDeleted` or `is_deleted` set to `true`) agrees with an entity the state does not hold, as a `404` does. Replay drops the entity such a read names, and such - an object never seeds or adopts an entity. Serving keeps an entity a served delete removed from such a type until a - reset or load, so a read of it by id answers its last fields with the flag `true`, as the application does. + an object never seeds or adopts an entity. The item view records the flag later reads set as `deleted_flag`, and + serving keeps an entity a served delete removed from such a type until a reset or load, so a read of it by id answers + its last fields with the flag `true`, as the application does. - **Page sizes.** `maxSize`, `max_results` and `maxResults` are page sizes, for a list read's whole-collection membership and for a list query's `limit` role. - **Fields only later reads show.** An item view also shows the members that reads after its type's first write show diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 84a83552a8..cc96f21dc9 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -846,7 +846,8 @@ the entity or list rendered into the envelope of the item view's or the line's base recording, with the members the state still holds. A 400, 404 or 410, a `null` body, or an object marked deleted (`deleted`, `isDeleted` or `is_deleted` `true`, as EspoCRM answers a read of a deleted record) agrees with an entity the -copy does not hold, and such an object never seeds or adopts one. A match confirms +copy does not hold, and such an object never seeds or adopts one; the item view +records the flag later reads set as `deleted_flag`, which serving answers by. A match confirms the writes that changed its entity or collection since it was last read, less those whose entity a read since showed absent, which no longer count for the collection; a mismatch contradicts them, or, when an unmodelled write came after them, is diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 50d1abef03..e7e6951ebf 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -2,9 +2,7 @@ import { hasRepeatedQueryArgument } from "./environmentTools.js"; import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js"; import { ownMember, - at, collectionMatch, - deletedFlag, collectionPathOf, decodedSegment, allLines, @@ -277,51 +275,18 @@ export const namedEntity = ( store.entity(type, collection, id), ); -/** Per Evidence, the member each type's recorded item reads set `true` on a soft-deleted entity, or null for none. */ -const deletedFlags = new WeakMap< - EnvironmentSource, - Map ->(); - -/** The member a type's recorded item reads set `true` on an entity the application soft-deleted, if they show one. */ +/** The member a type's item view learned its reads set `true` on an entity the application soft-deleted, if any. */ export const softDeleteFlag = ( serving: StateServing, type: string, ): string | undefined => { - let flags = deletedFlags.get(serving.source); - if (flags === undefined) { - flags = new Map(); - deletedFlags.set(serving.source, flags); - } - if (!flags.has(type)) - flags.set(type, recordedDeletedFlag(serving, type) ?? null); - return flags.get(type) ?? undefined; -}; - -const recordedDeletedFlag = ( - serving: StateServing, - type: string, -): string | undefined => { - for (const view of serving.store.state.views.values()) { - if (view.shape !== "item" || view.type !== type) continue; - const tool = serving.source.manifest.tools.find( - (candidate) => candidate.name === view.tool, - ); - for (const ordinal of tool?.record_ordinals ?? []) { - const exchange = serving.source.exchanges.get(ordinal); - if ( - exchange === undefined || - exchange.status === null || - exchange.status < 200 || - exchange.status > 299 - ) - continue; - const body = recordedJson(exchange); - const flag = - body === undefined ? undefined : deletedFlag(at(body, view.pointer)); - if (flag !== undefined) return flag; - } - } + for (const view of serving.store.state.views.values()) + if ( + view.shape === "item" && + view.type === type && + view.deleted_flag !== undefined + ) + return view.deleted_flag; return undefined; }; diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index bb388a8211..be8854da63 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -10,6 +10,7 @@ import { apiBaseLengths, at, collectionPathOf, + deletedFlag, identifies, IDENTITY_NAMES, identityCandidates, @@ -439,7 +440,10 @@ export class EnvironmentStateExtraction { candidate: Candidate, pointer: string, recordings: readonly StateRecordFacts[], - replayed: Pick = {}, + replayed: Pick< + StatePlan, + "projected" | "late" | "laterFields" | "deletedFlag" + > = {}, ): void { this.#plans.push({ ...candidate, @@ -452,19 +456,23 @@ export class EnvironmentStateExtraction { replayed.laterFields.length === 0 ? {} : { laterFields: replayed.laterFields }), + ...(replayed.deletedFlag === undefined + ? {} + : { deletedFlag: replayed.deletedFlag }), }); } - /** The members later plain item reads show of the entity they name, in first-seen order. */ + /** The members later plain item reads show of the entity they name, in first-seen order, and the flag they set on one the application soft-deleted. */ #laterFields( tool: StateToolFacts, type: string, entity: { readonly field: string; readonly pointer: string }, - ): string[] { + ): Pick { const limit = this.#initLimit(type); const names: string[] = []; const seen = new Set(); - if (limit === undefined) return names; + let flag: StatePlan["deletedFlag"]; + if (limit === undefined) return { laterFields: names }; for (const ordinal of tool.record_ordinals) { const record = this.#byOrdinal.get(ordinal); if ( @@ -481,6 +489,7 @@ export class EnvironmentStateExtraction { body === undefined ? undefined : at(body, entity.pointer); if (!isObject(object) || ownMember(object, entity.field) === undefined) return; + flag ??= deletedFlag(object); for (const name of Object.keys(object)) if (!seen.has(name)) { seen.add(name); @@ -489,7 +498,9 @@ export class EnvironmentStateExtraction { } }); } - return names; + return flag === undefined + ? { laterFields: names } + : { laterFields: names, deletedFlag: flag }; } /** @@ -599,8 +610,8 @@ export class EnvironmentStateExtraction { this.#chronology.positions.get(left)! - this.#chronology.positions.get(right)!, ); - const laterFields = this.#laterFields(tool, type, { field, pointer }); - this.#addPlan(candidate, pointer, kept, { projected, late, laterFields }); + const later = this.#laterFields(tool, type, { field, pointer }); + this.#addPlan(candidate, pointer, kept, { projected, late, ...later }); } /** diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index 9c610f4a49..7d38264e00 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -67,6 +67,8 @@ export interface StatePlan { readonly late?: readonly LateLine[]; /** The members later plain item reads show of the entity they name. */ readonly laterFields?: readonly string[]; + /** The member later plain item reads set `true` on an entity the application soft-deleted. */ + readonly deletedFlag?: StateItemView["deleted_flag"]; /** The query keys that pick calls of the replayed reads out. */ readonly projectedKeys?: readonly ReplayKey[]; } @@ -233,6 +235,9 @@ export const checkItemPlan = ( fields: union.fields, required_fields: union.required, envelope_ordinal: plan.latest[0]!.ordinal, + ...(plan.deletedFlag === undefined + ? {} + : { deleted_flag: plan.deletedFlag }), ...declaredMembers(envelope.members, context.retain), checked_record_ordinals: plan.latest.map((record) => record.ordinal), ...replayedReads(plan), diff --git a/src/domain/analysisView/environmentStateLaterReads.test.ts b/src/domain/analysisView/environmentStateLaterReads.test.ts index 2026459afe..19c28af4cc 100644 --- a/src/domain/analysisView/environmentStateLaterReads.test.ts +++ b/src/domain/analysisView/environmentStateLaterReads.test.ts @@ -29,6 +29,16 @@ describe("deletes the application answers for afterwards", () => { }); }); + it("learns on the item view the flag later reads set on a soft-deleted contact", () => { + const item = (rows: readonly TimedCaptureRow[]) => + viewOf(rows).views.find((view) => view.tool === "get_contacts_by_id"); + expect(item(SOFT_DELETED_ROWS)).toMatchObject({ + shape: "item", + deleted_flag: "deleted", + }); + expect(item(MAX_SIZE_ROWS)).not.toHaveProperty("deleted_flag"); + }); + it.each([ [{ deleted: true }, true], [{ isDeleted: true }, true], diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index a65edd01ad..5d289b455e 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -151,6 +151,8 @@ export type StateItemView = EntityViewPlan & ReplayedReads & { readonly shape: "item"; readonly envelope_ordinal: number; + /** The member later reads set `true` on an entity the application soft-deleted and still answers by id. */ + readonly deleted_flag?: (typeof DELETED_FLAGS)[number] | undefined; }; /** A query parameter that asks for some fields only (`fields`, `fields[...]`, `select`, `$select`). */ @@ -190,12 +192,12 @@ export type StateListView = EntityViewPlan & }; /** Members an application sets `true` on an entity it has soft-deleted but still answers for by id. */ -const DELETED_FLAGS = ["deleted", "isDeleted", "is_deleted"]; +export const DELETED_FLAGS = ["deleted", "isDeleted", "is_deleted"] as const; /** The member an item read's object sets `true` to mark the entity soft-deleted, if it does. */ export const deletedFlag = ( object: JsonValue | undefined, -): string | undefined => +): (typeof DELETED_FLAGS)[number] | undefined => isObject(object) ? DELETED_FLAGS.find((name) => ownMember(object, name) === true) : undefined; diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index a3768d4ef2..3b7a06d965 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -9,6 +9,7 @@ import type { UnsignedAnalysisView, } from "./analysisView.js"; import { EnvironmentStateAccumulator } from "./EnvironmentStateAccumulator.js"; +import { DELETED_FLAGS } from "./environmentStateRules.js"; import { isClockZone } from "./environmentStateWriteValues.js"; import { CLOCK_PATTERNS } from "./environmentStateWriteClockFormats.js"; import { @@ -168,6 +169,12 @@ const viewSchema = z.discriminatedUnion("shape", [ .describe( "Record whose response, with the entity object replaced, every item response shares.", ), + deleted_flag: z + .enum(DELETED_FLAGS) + .optional() + .describe( + "Member later reads set true on an entity the application soft-deleted.", + ), ...replayedReadFields, }), z.strictObject({