Files
rea/src/domain/analysisView/environmentStateWriteEffects.ts
T
Cursor Agent 7de20bc9ea fix(environment): credit sent fields, negative sequences, and encoded targets
A later overwrite copied surviving derived fields into the sent-member
claim, so a members-only check rejected an update whose sent field still
matched. Serving floored sequences at zero and stored missing history as
zero, so a collection sequence learned from negative ranks skipped the
next value. An absence after a delete forgot the resolved collection, and
an encoded read could not observe an update spelled with another
collection encoding.

Regression tests cover each case.

Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 22:49:11 +00:00

497 lines
16 KiB
TypeScript

import type { JsonValue } from "../jsonValue.js";
import { ok } from "../result.js";
import {
isObject,
isStateWrite,
locateItem,
at,
same,
type JsonObject,
type StateDocument,
type StateEntity,
type StateRecordFacts,
type StateToolFacts,
type StateType,
type StateView,
} from "./environmentStateRules.js";
import { updatedEntity, updatedFields } from "./environmentStateWriteApply.js";
import {
collectionTarget,
entityTarget,
itemTarget,
type SimulatedState,
} from "./environmentStateWriteChecks.js";
import {
conflictsWithPlan,
explainUpdate,
responseShapeOf,
} from "./environmentStateWriteExplain.js";
import {
laterObservation,
type Observed,
} from "./environmentStateWriteObserve.js";
import { integerOf, slugOf } from "./environmentStateWriteValues.js";
import {
pathValuesOf,
type FieldRule,
type InsertRule,
type StateWritePlan,
type WriteCall,
type WriteReason,
} from "./environmentStateWriteRules.js";
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
/** A plan under construction and the evidence the walk gathers for its verdict. */
export interface WriteDraft {
readonly plan: Mutable<StateWritePlan>;
/** The exemplar's observed entity, whose unruled fields a create keeps. */
observed: JsonObject | undefined;
candidates: InsertRule[];
readonly confirmed: number[];
contradictedBy: number | undefined;
sideEffect: boolean;
calls: number;
/** The scalar values each modelled update call showed and sent, which later rules must reproduce. */
readonly updates: {
readonly shown: ReadonlyMap<string, JsonValue>;
readonly sent: ReadonlyMap<string, JsonValue>;
}[];
/** The last value each sequence took, by field and, for a collection's sequence, collection; the next call must follow it. */
readonly sequences: Map<string, number>;
}
/** Response bodies, request bodies and budget the effects read. */
export interface WriteIo {
readonly withBody: <T>(
ordinal: number,
use: (body: JsonValue | undefined) => T,
) => T;
readonly requestBody: (ordinal: number) => JsonValue | undefined;
readonly emptyResponse: (ordinal: number) => boolean;
readonly retain: (bytes: number) => void;
}
/** What the write gate reads: the manifest tools, the timeline, the read views and the initial state. */
export interface WriteSimulationInput {
readonly tools: readonly StateToolFacts[];
readonly records: readonly StateRecordFacts[];
readonly timed: boolean;
readonly views: ReadonlyMap<string, StateView>;
readonly types: readonly StateType[];
readonly entities: readonly StateEntity[];
readonly documents: readonly StateDocument[];
readonly volatileOf: (type: string) => ReadonlySet<string>;
readonly io: WriteIo;
}
/** What applying a recorded write reads besides the draft and the call. */
export interface EffectContext {
readonly state: SimulatedState;
readonly io: WriteIo;
readonly records: readonly StateRecordFacts[];
readonly timed: boolean;
readonly viewOf: (ordinal: number) => StateView | undefined;
readonly idField: (type: string) => string;
/** Mark a later read as a create's or update's observation, so it does not check that write. */
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 as the initial state holds it: its path, and the value serving also reads member types from. */
readonly initialDocument: (tool: string) => StateDocument | undefined;
/** The entities of a type the initial state held, whose values a sequence never reuses. */
readonly startedWith: (type: string) => readonly StateEntity[];
}
/** One recorded write: the record, its timeline index, its tool, and whether its plan only takes the recorded outcome. */
export interface WriteStep {
readonly record: StateRecordFacts;
readonly index: number;
readonly tool: StateToolFacts;
/** Set once the plan has a reason: the write only brings the state to its recorded outcome. */
readonly observeOnly: boolean;
}
/**
* What applying a recorded write did: the modelled effect (attributable to the tool), the
* recorded outcome when the model does not explain it (not attributable), or nothing known.
*/
export type EffectResult =
| {
readonly kind: "applied";
readonly targets: readonly string[];
readonly entity?: WrittenEntity;
/** The fields an update wrote, or the pointers a document write wrote; a create writes the whole entity. */
readonly fields?: readonly string[];
/** Set when the fields are a document's pointers, each covering the members under it. */
readonly pointers?: boolean;
/** The fields an update sent or changed, derived ones included, and those of them its increments add to. */
readonly changed?: {
readonly fields: readonly string[];
readonly accumulated: readonly string[];
};
readonly inserted?: string;
}
| {
readonly kind: "observed";
readonly targets: readonly string[];
readonly entity?: WrittenEntity;
}
| { readonly kind: "unknown" };
/** The entity a write changed, by type, collection and id. */
export interface WrittenEntity {
readonly type: string;
readonly collection: string;
readonly id: string;
}
export const UNKNOWN: EffectResult = { kind: "unknown" };
export const built = (draft: WriteDraft): boolean =>
draft.plan.exemplar_ordinal !== null;
/** Give an unbuilt plan its reason; a built plan keeps its model. */
export const fail = (
draft: WriteDraft,
step: WriteStep,
reason: WriteReason,
): void => {
if (!built(draft) && !step.observeOnly) draft.plan.reason = reason;
};
/** A recorded call the built model does not explain disqualifies the plan. */
export const refuse = (
draft: WriteDraft,
step: WriteStep,
reason: WriteReason,
): void => {
if (!step.observeOnly) draft.plan.reason = reason;
};
export const requestObject = (
context: EffectContext,
record: StateRecordFacts,
): JsonObject | undefined => {
const body = context.io.requestBody(record.ordinal);
return isObject(body) ? body : undefined;
};
export const callOf = (
step: WriteStep,
body: JsonObject | undefined,
): WriteCall => ({
path: step.record.path,
pathValues:
pathValuesOf(step.tool.path_template, step.record.path) ?? new Map(),
body,
});
export const startOf = (
context: EffectContext,
step: WriteStep,
): number | undefined => (context.timed ? step.record.start : undefined);
export const searchFrom = (context: EffectContext, step: WriteStep) => ({
records: context.records,
from: step.index,
viewOf: context.viewOf,
withBody: context.io.withBody,
});
/** The entity an update's response shows, located by its identity, else a later item read of the target the write resolved. */
const updatedObservation = (
context: EffectContext,
step: WriteStep,
entity: {
readonly type: string;
readonly idField: string;
readonly collection: string;
readonly id: string;
},
): Observed | undefined => {
const own = context.io.withBody(
step.record.ordinal,
(body): Observed | undefined => {
const located =
body === undefined ? undefined : locateItem(body, entity.id);
const object =
located === undefined ? undefined : at(body!, located.pointer);
return located?.keys.includes(entity.idField) === true && isObject(object)
? {
ordinal: step.record.ordinal,
pointer: located.pointer,
object,
id: entity.id,
}
: undefined;
},
);
if (own !== undefined) return own;
const later = laterObservation(searchFrom(context, step), {
type: entity.type,
collection: entity.collection,
idField: entity.idField,
id: entity.id,
held: () => true,
isWrite: isStateWrite,
sameItem: (record) => {
const found = itemTarget(context.state, entity.type, record);
return found.collection === entity.collection && found.id === entity.id;
},
});
if (later !== undefined) context.observed(later.ordinal);
return later;
};
/** Add what a later call of an update showed to its plan: members, fixed values and rules not seen before. */
const refineUpdate = (
context: EffectContext,
plan: WriteDraft["plan"],
explained: ReturnType<typeof explainUpdate>,
): void => {
const body = plan.body!;
const fixed = new Set(body.fixed.map((entry) => entry.member));
const added = explained.body.fixed.filter(
(entry) => !fixed.has(entry.member),
);
retainFixed(context, added);
plan.body = {
...body,
// A later call may be the first to show the body repeating the path's id.
identity: body.identity ?? explained.body.identity,
accepted: [
...body.accepted,
...explained.body.accepted.filter(
(member) => !body.accepted.includes(member),
),
],
fixed: [...body.fixed, ...added],
};
const ruled = new Set(plan.rules.map((rule) => rule.field));
plan.rules = [
...plan.rules,
...explained.rules.filter((rule) => !ruled.has(rule.field)),
];
};
/** Count the sent values of fixed members a plan keeps, which outlive the request body they were read from. */
export const retainFixed = (
context: EffectContext,
fixed: readonly { readonly sent: JsonValue }[],
): void => {
if (fixed.length > 0)
context.io.retain(64 + JSON.stringify(fixed).length * 2);
};
/** 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";
/** A call's scalar members by name, as much of it as later rules are checked against. */
const scalars = (object: JsonObject): Map<string, JsonValue> =>
new Map(
Object.entries(object).filter(
([, value]) => value === null || typeof value !== "object",
),
);
/** Whether a rule a later call showed gives an earlier call a value other than the one it showed for a field it did not send. */
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 }) => {
// An unruled field an earlier call showed is one it left as it was.
const value = shown.get(rule.field);
if (value === undefined) return false;
if (rule.rule === "increment") return integerOf(value) !== undefined;
const from = rule.rule === "slug" ? sent.get(rule.from) : undefined;
return typeof from === "string" && slugOf(from) !== value;
}),
);
};
/** Build an update plan from its exemplar, or add what a later call showed. */
const buildUpdate = (
context: EffectContext,
draft: WriteDraft,
step: WriteStep,
evidence: {
readonly after: Observed;
readonly explained: ReturnType<typeof explainUpdate>;
readonly idField: string;
},
): void => {
if (built(draft)) {
refineUpdate(context, draft.plan, evidence.explained);
return;
}
retainFixed(context, evidence.explained.body.fixed);
const { ordinal } = step.record;
Object.assign(draft.plan, {
exemplar_ordinal: ordinal,
observation: {
ordinal: evidence.after.ordinal,
pointer: evidence.after.pointer,
},
response: context.io.withBody(ordinal, (response) =>
responseShapeOf(ordinal, response, context.io.emptyResponse(ordinal), {
idField: evidence.idField,
id: evidence.after.id,
}),
),
body: evidence.explained.body,
rules: evidence.explained.rules,
});
draft.observed = evidence.after.object;
};
/**
* Apply a recorded update to the held entity, explaining every change it made. An update the
* model does not explain, or of an entity an unmodelled write may have changed, brings the
* entity to what it recorded instead and is not attributed to the tool.
*/
export const applyUpdate = (
context: EffectContext,
draft: WriteDraft,
step: WriteStep,
): EffectResult => {
const type = draft.plan.target!;
const idField = context.idField(type);
// The entity as serving names it.
const { collection, id } = itemTarget(context.state, type, step.record);
const entity = context.state.entity(type, collection, id);
const after =
entity === undefined
? undefined
: updatedObservation(context, step, { type, idField, collection, id });
if (entity === undefined || after === undefined) {
fail(draft, step, "no-observation");
return UNKNOWN;
}
const targets = [
entityTarget(type, collection, id),
collectionTarget(type, collection),
];
const target = { type, collection, id };
const recorded = (): EffectResult => {
context.state.put(
updatedEntity(entity, { ...entity.fields, ...after.object }),
);
return { kind: "observed", targets, entity: target };
};
const body = requestObject(context, step.record);
if (step.observeOnly || body === undefined) {
fail(draft, step, "body-unusable");
return recorded();
}
const unknown = context.unknown(type, collection, id);
const call = { ...callOf(step, body), body };
const explained = explainUpdate({
before: entity.fields,
after: after.object,
call,
method: step.tool.method,
idField,
id,
startMs: startOf(context, step),
read: after.ordinal !== step.record.ordinal,
});
if (explained.unexplained.length > 0 && !unknown) {
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) ||
conflictsWithPlan(draft.plan, explained))
) {
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(
draft.plan,
{ before: entity.fields, observed: draft.observed! },
{ ...call, id },
(rule) => ok(after.object[rule.field] ?? null),
);
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 facts = { shown: scalars(after.object), sent: scalars(body) };
context.io.retain(
64 + JSON.stringify([...facts.shown, ...facts.sent]).length * 2,
);
draft.updates.push(facts);
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.
const sent = Object.keys(body).filter(
(member) => member !== draft.plan.body?.identity,
);
const derived = Object.keys(written.value).filter(
(field) => !same(written.value[field], entity.fields[field]),
);
const changed = {
fields: [...new Set([...sent, ...derived])],
accumulated: draft.plan.rules.flatMap((rule) =>
rule.rule === "increment" ? [rule.field] : [],
),
};
return { kind: "applied", targets, entity: target, fields: sent, changed };
};
/** Apply a recorded delete: the held entity leaves its collection. */
export const applyDelete = (
context: EffectContext,
draft: WriteDraft,
step: WriteStep,
): EffectResult => {
const type = draft.plan.target!;
const { collection, id } = itemTarget(context.state, type, step.record);
const entity = context.state.entity(type, collection, id);
if (entity === undefined) {
fail(draft, step, "no-observation");
return UNKNOWN;
}
const { ordinal } = step.record;
if (!built(draft) && !step.observeOnly)
Object.assign(draft.plan, {
exemplar_ordinal: ordinal,
response: context.io.withBody(ordinal, (body) =>
responseShapeOf(ordinal, body, context.io.emptyResponse(ordinal), {
idField: context.idField(type),
id,
}),
),
});
context.state.remove(entity);
const targets = [
entityTarget(type, collection, id),
collectionTarget(type, collection),
];
return {
kind: step.observeOnly ? "observed" : "applied",
targets,
entity: { type, collection, id },
};
};