* feat(environment): explain values an application derives from other fields
Prompt-built environments withheld creates whose responses carry values the
application derives deterministically, which the write model kept as
constants or could not match across calls:
- A composed string is literal text plus parts read from the fields other
rules write: a field's value, its slug, or a clock's calendar digits. A
create composes a string only when another entity of the type spells it
the same way and none spells it otherwise, and never from two sources
serving could tell apart; an update composes an unsent change from members
stored as sent and unchanged fields that also spell its value before the
call. A composed field no longer pins the members it reads.
- A trimmed clock format drops trailing fraction zeros, as Go spells
nanoseconds. It is learned from the type's values, a later create or a
later update, and spreads to the plan's other clocks unless a value shows
a fixed width.
- A large integer no entity of the type holds, among distinct ones of the
same length and kind, is a generated unique value, served as a seeded
integer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the derived-values verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): learn a create plan from every call
A later create the plan does not take is explained on its own and merged when both calls show the same fields, every field both rule they rule alike, and a field neither rules holds one 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, kept in the body's defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(environment): sorted arrays, referenced entities and nested view keys
- An echo is `sorted` when the application stored the sent strings in
code-point order; serving stores them sorted, and a plain echo and a
sorted one agree in update and create plans.
- A `reference` is an object field holding fields of the entity a sent
member names, as the state holds it. The member is accepted with any
value; replay and serving look the entity up, and serving refuses a call
naming one the environment does not hold.
- A view names in `nested_fields` the keys it always shows of objects it
shows with fewer keys than an entity stores; a stored object it never
recorded renders with them. The view spelling learner moves to its own
module.
- Every later create, not only one the plan refuses, is merged into its
plan when it agrees, keeping the plan's rule order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep clock spellings, linked ids and word boundaries honest
- A later update's clocks take the plan's spelling, sibling-trimmed
clocks included, wherever the call's values fit it, so an update whose
later call trims only one of two clocks is served.
- A held value that ends its fraction in a zero keeps a create's clock
at a fixed width, whatever order the held spellings come in.
- A large integer that another field of the created entity also holds
is linked, as a repeated token is: it is never unique, and the create
is withheld as unexplained-change.
- Composed parts are delimited by whole characters through the shared
joinsBefore/joinsAfter, so an astral letter joins a match to its word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): keep a same-width fixed clock out of trimming
A held .123456780 beside a held .12345678 keeps the create's clock at
nine digits in either order; the cases fail on 8ce1127b5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): record the create-calls verification
Gates, tests, write plans over 69 recorded captures and served write tasks on the derived-values baseline and on this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(environment): note the create-calls census on current main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): check composed parts and clock fits before they shape a plan
- A composed string whose part has nothing to spell refuses the create
or update through the apply Result instead of keeping a stale value.
- A composition learned on a later call is rebuilt from each earlier
call's fields, so an earlier call that left the value otherwise
contradicts it.
- An update adopts its fitted clock spellings only after the
unknown-entity and contradiction checks, so a call on an entity an
unmodelled write left unknown no longer trims a served plan's clock.
- Sibling trimming on a later create also checks that call's fields,
so a sibling clock it shows with a trailing zero stays fixed-width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(environment): split the derived-values view tests by rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep fixed-width, zone and generated-integer evidence through a plan
- A plan keeps the first value that showed each clock at a fixed width,
from held entities and every accepted create or update. No later fit
or sibling trim widens that field, and a later call that shows it
shorter is withheld.
- Two stamped clocks are one composed source only when they share a
zone offset. A held entity that holds the target field as anything
the parts do not spell, a non-string included, vetoes the
composition. The token index is built once, and only when a string is
left to compose.
- A generated integer that another field repeats as a number at any
depth is linked. Compare treats two integers of one JSON kind and
length under a unique rule as generated values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): keep optional references across create calls and in answers
- A reference a later create added stays while further creates leave its
member out, and agrees with a later call that shows it again.
- A create's answer shows optional reference and echo fields only when the
call wrote them, whichever recorded call the answer came from.
- An update that re-sends the id of the entity a field already repeats keeps
the plan's reference instead of pinning the member.
- An id naming entities of one type in several collections teaches no
reference, and serving or replay refuses a call that names one.
- An echo is not promoted to sorted once a call stored its strings out of
code-point order as sent.
- Nested view keys compare as sets and keep the first recording's order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(setup): share registration prewarm host preparation
* fix(environment): retain write evidence and match trimmed response clocks
* fix(environment): withhold creates whose generated integer another string repeats
A unique integer whose digits show in another top-level string that no
template or composed rule rebuilds from it (an echoed serialText, or a
token like folder-<id>) withholds the create as unexplained-change,
since serving would seed the integer and keep the string. A templated
copy is still served.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): respect earlier clock widths and left-out members in later creates
- The create merge widens a clock to trimmed only against the value that
showed its width: a held entity or accepted call at a fixed width keeps it
untrimmed.
- A later create that left out a member the plan fills, with a default or a
reference's absence, while its observation shows that field otherwise is
unexplained-change, so the plan cannot serve the stale value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): hold later creates and compare to the composed and integer forms
- A later create whose response spells a composed field otherwise than
the plan rebuilds it is withheld as unexplained-change.
- A unique field learned from a generated integer accepts only an
integer of the same length and kind from a later create, as serving
seeds one.
- Compare counts a composed field as equal when each side's response
spells it from its own fields, which compare under their own rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(environment): preserve composed parts and unique generator families
* fix(environment): reject contradictory create references
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
732 lines
25 KiB
TypeScript
732 lines
25 KiB
TypeScript
import { jsonTextCodeUnits } from "../jsonSerialization.js";
|
|
import type { JsonValue } from "../jsonValue.js";
|
|
import {
|
|
isObject,
|
|
locateItem,
|
|
at,
|
|
type JsonObject,
|
|
type ParsedRequestBody,
|
|
requestJson,
|
|
type StateDocument,
|
|
type StateEntity,
|
|
type StateRecordFacts,
|
|
type StateToolFacts,
|
|
type StateType,
|
|
type StateView,
|
|
} from "./environmentStateRules.js";
|
|
import {
|
|
updatedEntity,
|
|
updatedFields,
|
|
recordedGeneratedValue,
|
|
} from "./environmentStateWriteApply.js";
|
|
import {
|
|
collectionTarget,
|
|
entityTarget,
|
|
itemTarget,
|
|
type SimulatedState,
|
|
} from "./environmentStateWriteChecks.js";
|
|
import {
|
|
explainUpdate,
|
|
responseShapeOf,
|
|
} from "./environmentStateWriteExplain.js";
|
|
import { otherFields } from "./environmentStateWriteGenerated.js";
|
|
import {
|
|
contradictsEarlierCalls,
|
|
disagreesWithCall,
|
|
keptFields,
|
|
rememberCall,
|
|
appliedUpdate,
|
|
type UpdateCall,
|
|
conflictsWithPlan,
|
|
refinedBody,
|
|
} from "./environmentStateWriteCalls.js";
|
|
import {
|
|
laterObservation,
|
|
shownBefore,
|
|
viewFields,
|
|
type Observed,
|
|
} from "./environmentStateWriteObserve.js";
|
|
import { plannedBody, restampedFields } from "./environmentStateWriteStamps.js";
|
|
import { readClock } from "./environmentStateWriteValues.js";
|
|
import {
|
|
fittedClockFormat,
|
|
matchesClockFormat,
|
|
noteFixedWidths,
|
|
} from "./environmentStateWriteClockFormats.js";
|
|
import {
|
|
actionItemPath,
|
|
observationNeeds,
|
|
} from "./environmentStateWriteAddress.js";
|
|
import {
|
|
alikeRules,
|
|
keptOrders,
|
|
pathValuesOf,
|
|
siblingTrimmedClocks,
|
|
type FieldRule,
|
|
type InsertRule,
|
|
type StateWritePlan,
|
|
type WriteCall,
|
|
type WriteReason,
|
|
type WriteResponse,
|
|
} 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, and the fields it kept, which later rules must reproduce. */
|
|
readonly updates: UpdateCall[];
|
|
/** 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>;
|
|
/** The collection a create writes into when its own path names no state type: the one its returned id was read under. */
|
|
readonly linked?: string | undefined;
|
|
/** Fields an item action's call found already at the value it sets them to. */
|
|
readonly steady: Set<string>;
|
|
/** Unsent clocks a call left as they were in its own second, which it cannot show it stamps. */
|
|
readonly unstamped: Set<string>;
|
|
/** Members a call stored as sent with strings out of code-point order, which no plan may store sorted. */
|
|
readonly kept: Set<string>;
|
|
/** A value a held entity or an accepted call showed each clock field at a fixed width in, which keeps it untrimmed. */
|
|
readonly fixedWidth: Map<string, JsonValue>;
|
|
}
|
|
|
|
/** The value a draft fits a clock field against: one showing it at a fixed width, else the exemplar's. */
|
|
export const clockEvidence = (
|
|
draft: WriteDraft,
|
|
field: string,
|
|
): JsonValue | undefined =>
|
|
draft.fixedWidth.get(field) ?? draft.observed?.[field];
|
|
|
|
/** A write observation plus the fields its selected read view actually covers, if any. */
|
|
type UpdateObservation = Observed & {
|
|
readonly readFields?: readonly string[];
|
|
};
|
|
|
|
/** 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) => ParsedRequestBody;
|
|
readonly withRequestBody: <T>(
|
|
ordinal: number,
|
|
use: (body: ParsedRequestBody) => T,
|
|
) => T;
|
|
readonly emptyResponse: (ordinal: number) => boolean;
|
|
readonly retain: (bytes: number) => void;
|
|
readonly queryOf: (ordinal: number) => string | null | undefined;
|
|
}
|
|
|
|
/** 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[];
|
|
/** POST tools whose calls read state rather than change it. */
|
|
readonly readOnly: ReadonlySet<string>;
|
|
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;
|
|
/** Whether a record may change state: a write the capture does not show reading only. */
|
|
readonly isWrite: (record: StateRecordFacts) => 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[];
|
|
/** Whether another tool's modelled write to the target awaits a check, which a later read must still make. */
|
|
readonly pending: (target: string) => boolean;
|
|
}
|
|
|
|
/** 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;
|
|
/** Whether the call sent the query serving pins, its exemplar's, as every call before the plan is built does. */
|
|
readonly pinned: boolean;
|
|
/** A bulk response already classified by its owning body lease. */
|
|
readonly response?: WriteResponse;
|
|
/** Its entity at the exact source pointer, or null when that response shows none. */
|
|
readonly responseObservation?: Observed | null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** A document replacement's object, as a pointer: it overwrites every earlier effect under it, though it claims only its members. */
|
|
readonly overwrites?: readonly string[];
|
|
/** 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;
|
|
/** Validated update-response fields, before a later read restamps stored clocks. */
|
|
readonly responseFields?: JsonObject;
|
|
}
|
|
| {
|
|
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;
|
|
};
|
|
|
|
/** Shared request interpretation: the object's own fields take precedence over a one-element envelope. */
|
|
export const requestBodyPlan = (
|
|
context: EffectContext,
|
|
step: WriteStep,
|
|
draft: WriteDraft,
|
|
observed?: JsonObject,
|
|
) =>
|
|
step.pinned
|
|
? plannedBody(
|
|
requestJson(context.io.requestBody(step.record.ordinal)),
|
|
{ built: built(draft), envelope: draft.plan.body?.envelope },
|
|
(member) => observed !== undefined && Object.hasOwn(observed, member),
|
|
)
|
|
: 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,
|
|
});
|
|
|
|
/** The fields of each entity of a type with an id, as the replayed state holds them. */
|
|
export const referencedIn =
|
|
(context: EffectContext) =>
|
|
(type: string, id: string): JsonObject[] =>
|
|
context.state
|
|
.ofType(type)
|
|
.flatMap((entity) => (entity.id === id ? [entity.fields] : []));
|
|
|
|
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,
|
|
...(context.timed ? { after: step.record.end } : {}),
|
|
viewOf: context.viewOf,
|
|
withBody: context.io.withBody,
|
|
queryOf: context.io.queryOf,
|
|
});
|
|
|
|
/** The later item reads of the entity a write resolved, as a forward search finds them. */
|
|
export const itemReads = (
|
|
context: EffectContext,
|
|
entity: {
|
|
readonly type: string;
|
|
readonly idField: string;
|
|
readonly collection: string;
|
|
readonly id: string;
|
|
},
|
|
) => ({
|
|
...entity,
|
|
held: () => true,
|
|
isWrite: context.isWrite,
|
|
sameItem: (record: StateRecordFacts) => {
|
|
const found = itemTarget(context.state, entity.type, record);
|
|
return found.collection === entity.collection && found.id === entity.id;
|
|
},
|
|
});
|
|
|
|
/** 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;
|
|
},
|
|
observing: {
|
|
readonly required: readonly string[];
|
|
readonly borrow: boolean;
|
|
},
|
|
): UpdateObservation | undefined => {
|
|
const { required, borrow } = observing;
|
|
const own =
|
|
step.responseObservation === undefined
|
|
? 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;
|
|
},
|
|
)
|
|
: (step.responseObservation ?? undefined);
|
|
if (
|
|
own !== undefined &&
|
|
required.every((field) => Object.hasOwn(own.object, field))
|
|
)
|
|
return own;
|
|
if (!borrow) return undefined;
|
|
const later = laterObservation(searchFrom(context, step), {
|
|
...itemReads(context, entity),
|
|
// With an answer or without one, only a read whose view shows every learned clock shows what this call stamped.
|
|
covers: (view) => required.every((field) => view.fields.includes(field)),
|
|
});
|
|
if (later === undefined) return own;
|
|
const readFields = viewFields(context.viewOf(later.ordinal));
|
|
context.observed(later.ordinal);
|
|
const object = { ...own?.object, ...later.object };
|
|
// For covered fields, the later read's omission is evidence too; do not fill it back
|
|
// from an earlier write response. Fields outside that view retain response evidence.
|
|
for (const field of readFields ?? [])
|
|
if (!Object.hasOwn(later.object, field)) delete object[field];
|
|
return {
|
|
...later,
|
|
object,
|
|
...(readFields === undefined ? {} : { readFields }),
|
|
};
|
|
};
|
|
|
|
/** Add what a later call of an update showed to its plan: members, their stored types, fixed values and rules not seen before. */
|
|
const refineUpdate = (
|
|
context: EffectContext,
|
|
draft: WriteDraft,
|
|
evidence: {
|
|
readonly after: Observed;
|
|
readonly explained: ReturnType<typeof explainUpdate>;
|
|
},
|
|
): void => {
|
|
const { plan } = draft;
|
|
const body = refinedBody(plan.body!, evidence.explained.body, {
|
|
observed: draft.observed!,
|
|
stored: evidence.after.object,
|
|
});
|
|
retainFixed(context, body.fixed.slice(plan.body!.fixed.length));
|
|
plan.body = body;
|
|
const ruled = new Set(plan.rules.map((rule) => rule.field));
|
|
const later = new Map(
|
|
evidence.explained.rules.map((rule) => [rule.field, rule]),
|
|
);
|
|
plan.rules = [
|
|
// A later call may be the first to show an echoed array sorted.
|
|
...plan.rules.map((rule) => {
|
|
const shown = later.get(rule.field);
|
|
return shown === undefined
|
|
? rule
|
|
: (alikeRules(rule, shown, draft.kept) ?? rule);
|
|
}),
|
|
...evidence.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 + jsonTextCodeUnits(fixed) * 2);
|
|
};
|
|
|
|
/** An update's targets, and its recorded outcome: the entity takes what its observation shows, attributed to no tool. */
|
|
const outcomes = (
|
|
context: EffectContext,
|
|
entity: StateEntity,
|
|
shown: JsonObject,
|
|
) => {
|
|
const { type, collection, id } = entity;
|
|
const targets = [
|
|
entityTarget(type, collection, id),
|
|
collectionTarget(type, collection),
|
|
];
|
|
const target = { type, collection, id };
|
|
const recorded = (): EffectResult => {
|
|
context.state.put(updatedEntity(entity, { ...entity.fields, ...shown }));
|
|
return { kind: "observed", targets, entity: target };
|
|
};
|
|
return { targets, target, recorded };
|
|
};
|
|
|
|
/** 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, evidence);
|
|
return;
|
|
}
|
|
retainFixed(context, evidence.explained.body.fixed);
|
|
const { ordinal } = step.record;
|
|
const envelope = requestBodyPlan(
|
|
context,
|
|
step,
|
|
draft,
|
|
evidence.after.object,
|
|
)?.envelope;
|
|
Object.assign(draft.plan, {
|
|
exemplar_ordinal: ordinal,
|
|
observation: {
|
|
ordinal: evidence.after.ordinal,
|
|
pointer: evidence.after.pointer,
|
|
},
|
|
response:
|
|
step.response ??
|
|
context.io.withBody(ordinal, (response) =>
|
|
responseShapeOf(ordinal, response, context.io.emptyResponse(ordinal), {
|
|
idField: evidence.idField,
|
|
id: evidence.after.id,
|
|
}),
|
|
),
|
|
body: {
|
|
...evidence.explained.body,
|
|
...(envelope === undefined ? {} : { envelope }),
|
|
},
|
|
rules: evidence.explained.rules,
|
|
});
|
|
draft.observed = evidence.after.object;
|
|
for (const entity of context.state.ofType(draft.plan.target!))
|
|
noteFixedWidths(draft.fixedWidth, entity.fields);
|
|
};
|
|
|
|
/**
|
|
* 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 action = draft.plan.address?.by === "action";
|
|
// The entity as serving names it.
|
|
const named = itemTarget(context.state, draft.plan.target!, {
|
|
path: action ? actionItemPath(step.record.path) : step.record.path,
|
|
});
|
|
return updateEntity(context, draft, step, { ...named, request: true });
|
|
};
|
|
|
|
/** Apply a recorded update to the held entity a call names, with the members it wrote to that entity. */
|
|
export const updateEntity = (
|
|
context: EffectContext,
|
|
draft: WriteDraft,
|
|
step: WriteStep,
|
|
named: {
|
|
readonly collection: string;
|
|
readonly id: string;
|
|
} & ({ readonly request: true } | { readonly body: JsonObject | undefined }),
|
|
): EffectResult => {
|
|
const type = draft.plan.target!;
|
|
const idField = context.idField(type);
|
|
const { collection, id } = named;
|
|
const needs = observationNeeds(
|
|
draft.plan,
|
|
{
|
|
collection,
|
|
id,
|
|
type,
|
|
idField,
|
|
body: "body" in named ? named.body : undefined,
|
|
},
|
|
context.pending,
|
|
);
|
|
const entity = context.state.entity(type, collection, id);
|
|
const after =
|
|
entity === undefined
|
|
? undefined
|
|
: updatedObservation(
|
|
context,
|
|
step,
|
|
{ type, idField, collection, id },
|
|
needs,
|
|
);
|
|
if (entity === undefined || after === undefined) {
|
|
fail(draft, step, "no-observation");
|
|
return UNKNOWN;
|
|
}
|
|
const { targets, target, recorded } = outcomes(context, entity, after.object);
|
|
// Only confirmed absence makes a bodyless action; malformed or unretained content stays unknown.
|
|
const body =
|
|
"body" in named
|
|
? named.body
|
|
: draft.plan.address?.by === "action" &&
|
|
step.pinned &&
|
|
context.io.requestBody(step.record.ordinal).kind === "absent"
|
|
? {}
|
|
: requestBodyPlan(context, step, draft, after.object)?.object;
|
|
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, rules } = fitUpdateClocks(
|
|
draft,
|
|
after.object,
|
|
explainUpdate({
|
|
before: entity.fields,
|
|
after: after.object,
|
|
call,
|
|
method: step.tool.method,
|
|
idField,
|
|
id,
|
|
startMs: startOf(context, step),
|
|
...(after.readFields === undefined
|
|
? {}
|
|
: { readFields: after.readFields }),
|
|
others: otherFields(context.state.ofType(type), { collection, id }),
|
|
shown: shownBefore(context.viewOf, entity, after.ordinal),
|
|
named: (named) => context.state.named(named),
|
|
action: needs.action,
|
|
references: draft.plan.rules.filter((rule) => rule.rule === "reference"),
|
|
}),
|
|
);
|
|
for (const member of keptOrders(call.body, after.object))
|
|
draft.kept.add(member);
|
|
if (explained.unexplained.length > 0 && !unknown) {
|
|
refuse(draft, step, "unexplained-change");
|
|
return recorded();
|
|
}
|
|
// Each call must agree with the plan; one on an unknown entity cannot be checked, so it adds nothing to the plan.
|
|
if (
|
|
built(draft) &&
|
|
(unknown ||
|
|
contradictsPlan(
|
|
{ ...draft, plan: { ...draft.plan, rules } },
|
|
entity.fields,
|
|
after.object,
|
|
explained,
|
|
))
|
|
) {
|
|
if (!unknown) refuse(draft, step, "unexplained-change");
|
|
return recorded();
|
|
}
|
|
draft.plan.rules = rules;
|
|
noteFixedWidths(draft.fixedWidth, after.object);
|
|
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) =>
|
|
recordedGeneratedValue(
|
|
rule,
|
|
entity.fields[rule.field],
|
|
after.object[rule.field],
|
|
),
|
|
referencedIn(context),
|
|
);
|
|
const kept = keptFields(entity.fields, after.object);
|
|
const disagrees =
|
|
written.ok &&
|
|
disagreesWithCall(draft.plan.rules, {
|
|
written: written.value,
|
|
after: after.object,
|
|
kept,
|
|
});
|
|
if (!written.ok || (disagrees && !unknown))
|
|
refuse(draft, step, "unexplained-change");
|
|
if (!written.ok || disagrees || unknown) return recorded();
|
|
rememberCall(
|
|
context.io.retain,
|
|
draft,
|
|
{ before: entity.fields, after: after.object, sent: body, kept },
|
|
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,
|
|
draft.plan.rules,
|
|
);
|
|
context.io.retain(
|
|
64 +
|
|
jsonTextCodeUnits(stored) * 2 +
|
|
(stored === written.value ? 0 : jsonTextCodeUnits(written.value) * 2),
|
|
);
|
|
context.state.put(updatedEntity(entity, stored));
|
|
return {
|
|
...appliedUpdate(draft.plan, { targets, target }, entity.fields, {
|
|
written: stored,
|
|
body,
|
|
}),
|
|
responseFields: written.value,
|
|
};
|
|
};
|
|
|
|
/** A later update's clocks in the plan's format, and the plan's rules widened to the trimmed spelling the call showed when one covers both, its siblings with it wherever the call's values fit; the plan adopts them only once the call is checked. */
|
|
const fitUpdateClocks = (
|
|
draft: WriteDraft,
|
|
after: JsonObject,
|
|
explained: ReturnType<typeof explainUpdate>,
|
|
): {
|
|
readonly explained: ReturnType<typeof explainUpdate>;
|
|
readonly rules: FieldRule[];
|
|
} => {
|
|
if (!built(draft)) return { explained, rules: draft.plan.rules };
|
|
const rules = draft.plan.rules.map((rule) => {
|
|
const later = explained.rules.find((other) => other.field === rule.field);
|
|
if (
|
|
rule.rule !== "clock" ||
|
|
later?.rule !== "clock" ||
|
|
JSON.stringify(later.format) === JSON.stringify(rule.format)
|
|
)
|
|
return rule;
|
|
const format = fittedClockFormat(
|
|
rule.format,
|
|
after[rule.field],
|
|
clockEvidence(draft, rule.field),
|
|
);
|
|
return format === undefined ? rule : { ...rule, format };
|
|
});
|
|
const fitted = siblingTrimmedClocks(
|
|
rules,
|
|
draft.observed,
|
|
after,
|
|
Object.fromEntries(draft.fixedWidth),
|
|
);
|
|
const planned = new Map(fitted.map((rule) => [rule.field, rule]));
|
|
return {
|
|
explained: {
|
|
...explained,
|
|
rules: explained.rules.map((rule) => {
|
|
const held = planned.get(rule.field);
|
|
return rule.rule === "clock" &&
|
|
held?.rule === "clock" &&
|
|
matchesClockFormat(held.format, readClock(after[rule.field]))
|
|
? { ...rule, format: held.format }
|
|
: rule;
|
|
}),
|
|
},
|
|
rules: fitted,
|
|
};
|
|
};
|
|
|
|
/** Serving regenerates a learned clock on every update, so recorded calls must advance it. */
|
|
const contradictsGeneratedClock = (
|
|
before: JsonObject,
|
|
after: JsonObject,
|
|
rules: readonly FieldRule[],
|
|
explained: ReturnType<typeof explainUpdate>,
|
|
): boolean =>
|
|
rules.some((rule) => {
|
|
// A clock left in the call's own second cannot show whether it advanced.
|
|
if (
|
|
rule.rule !== "clock" ||
|
|
!Object.hasOwn(after, rule.field) ||
|
|
explained.unstamped.includes(rule.field)
|
|
)
|
|
return false;
|
|
const previous = readClock(before[rule.field]);
|
|
if (previous === undefined) return false;
|
|
const next = readClock(after[rule.field]);
|
|
return (
|
|
!explained.rules.some(
|
|
(candidate) =>
|
|
candidate.field === rule.field && candidate.rule === "clock",
|
|
) ||
|
|
next === undefined ||
|
|
next.ms <= previous.ms
|
|
);
|
|
});
|
|
|
|
/** Whether a later call explains its changes otherwise than the plan: rules for unsent changes hold for every call, so each call must agree with the others. */
|
|
const contradictsPlan = (
|
|
draft: WriteDraft,
|
|
before: JsonObject,
|
|
after: JsonObject,
|
|
explained: ReturnType<typeof explainUpdate>,
|
|
): boolean =>
|
|
contradictsEarlierCalls(draft, explained.rules) ||
|
|
contradictsGeneratedClock(before, after, draft.plan.rules, explained) ||
|
|
conflictsWithPlan(draft.plan, explained, draft.kept);
|