feat(environment): serve creates whose answers show less than reads, and whole-object updates (#7080)

* feat(environment): take created entities' read-backs and re-derived update members

- A create's observation takes the fields the first read of its entity
  shows that the answer lacks, and an item read's clock spellings, when the
  read agrees with the answer otherwise. The plan records the read
  (observation.read with its shape); an item read then observes rather than
  checks, a list read only adds fields and still checks. Serving merges the
  same recorded objects.
- An update member sent back as the clock the entity held and stored as a
  fresh clock near the call is a clock rule; one stored as the slug of
  another member stored as sent is a slug rule. Both are accepted with any
  value, as whole-object writes send what they read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record the write-views verification

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): reconcile write views without hiding contradictions

* fix(environment): reject incompatible slug-only saves

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-25 15:06:27 +08:00
1 parent 80673b22a4
commit 113679c9a6
18 files changed
+1000 -33

No files matched your search

@@ -0,0 +1,8 @@
# Implementation plan
1. **Read-backs.** A create's observation looks for the first read of its entity before the next write; an item read's
fields and clock spellings are taken when it agrees otherwise, and the read observes; a list read adds fields only. The plan's observation records the
read and its shape, and serving merges the same objects.
2. **Sent members.** An update's sent member stored as a fresh clock after it sent back the held one is a `clock`; one
stored as the slug of another member stored as sent is a `slug`.
3. **Schema, tests and docs.**
@@ -0,0 +1,47 @@
# Writes whose answers show the entity otherwise than reads, and whole-object updates
Issue: https://github.com/morluto/rea/issues/4933
## Outcome
Agents built environments from a prompt for five applications the prompt flow had never seen (Vikunja, Ghost, EspoCRM,
Paperless-ngx and Miniflux). Two write-model gaps withheld most of their creates and edits:
- **A create's answer is another view of the entity than its reads.** Paperless answers a tag create without the
`document_count` its item read shows. EspoCRM answers a contact create without nine fields its item read shows.
Vikunja answers a label create with a partial `created_by` and clocks to the nanosecond, where reads show the full
user and whole seconds. Ghost answers a tag create without the `count` its list shows. The state built the entity from
the answer alone, so the read-back contradicted the create.
- **A whole-object update sends back what the application derives.** Paperless's UI sends the tag's old `slug` with a
new name, and the application slugs the name anew. Ghost's UI sends the `updated_at` it read, and the application
stamps a new one. The update was withheld as `unexplained-change`, or its first call's value was pinned so that later
calls conflicted.
## Behaviour
- **Read-backs.** When the first read of a created entity before the next write shows fields the create's answer lacks,
or spells its clocks to another precision, the observed entity takes them, and the plan records the read
(`observation.read`, with its shape).
- An item read that agrees with the answer otherwise lends its fields and its clock spellings (whole seconds of a
nanosecond clock, or microseconds of one), and no longer confirms the create. One that disagrees on another value,
or omits an answer field its item view renders, still checks the create, which it contradicts.
- A list read, which may show fewer keys of an object, only adds fields the answer lacks and still checks the create.
- A read that shows nothing new leaves the observation as it was and still confirms the create.
- Serving merges the same two recorded objects, so a served create answers in its answer's shape and stores the
fields of both. Generated clocks in the create answer retain that answer's recorded precision independently of
the stored read-back precision.
- **Restamped clocks.** An update member sent back as the clock the entity held, and stored as a fresh clock near the
call, is a `clock` rule, accepted with any value.
- **Re-derived slugs.** An update member stored as the slug of another member the call stored as sent is a `slug` rule
from that member, accepted with any value.
- **Compatible saves.** A call that echoes an already-derived slug or holds a clock in its own recorded second does
not conflict with a demonstrated derivation, in either call order. A slug derivation must reproduce earlier saves
as well; an explicit custom slug that disagrees still withholds the update.
Plans and views that need none of this are unchanged.
## Acceptance
- The held-out recordings serve EspoCRM's contact create and Paperless's tag edit.
- A create whose item read-back disagrees with its answer on another value is still contradicted.
- No write tool of the 69 recorded captures goes from served to withheld.
@@ -0,0 +1,8 @@
# Tasks
- [x] Take a create's read-back fields into its observation, and serve the same merge.
- [x] Learn restamped clocks and re-derived slugs for sent update members.
- [x] Add view and serving tests that fail without the change.
- [x] Document the observation member and the rules.
- [x] Measure the held-out and recorded captures.
- [x] Run static, formatting, documentation and test gates.
@@ -0,0 +1,48 @@
# Verification
Executed on macOS with Node v24.22.1 at `2f807efb` (main `8e6a9578` and this branch).
## Gates
- `npm run check` (build, typecheck, format, lint, typed lint, module boundaries, architecture guards) and
`npm run docs:check`: passed.
- `npx vitest run --project domain --project services --project adapters`: 17,624 passed, 25 skipped, 1 failed ("stops
Inspector target authorization after the current stat settles"), which fails the same way on main in this environment.
- `npm run test:focused --` the analysis-view, environment build, compare, export and serve CLI boundaries, the
analysis-view, environment bundle, serve and served-writes MCP boundaries, tool schema validity, output schema profile
and MCP schema validation: 12 files, 106 passed.
- The eight new view and serving tests fail on main and pass here.
## Recorded captures
The 69 recorded captures and the five held-out agent recordings, built as environment-state views on main and on this
branch:
| Captures | Served on main | Served here | Served to withheld |
| ----------------------------------------- | -------------- | ----------- | ------------------ |
| 69 recorded | 132 | 132 | none |
| 5 held-out (20 write tools with a target) | 8 | 10 | none |
- **Now served:** EspoCRM's contact create (its read-back adds nine fields the answer lacks) and Paperless's tag edit
(the stale slug is slugged anew from the name).
- **Past the read-back, withheld by other gaps:**
- Paperless's tag create: its list's `all` member lists every id, which state rendering does not model.
- Ghost's tag create and edit: the state does not list the created tags in Ghost's tag lists.
- **Still contradicted:** Vikunja's label create, whose item read-back shows a fully loaded `created_by` where the answer
showed a partial one.
- Three plans of the recorded captures (Airbyte, BookStack, PhotoPrism) now record a read-back; their served state is
unchanged.
## Served write tasks
The held-out round's write tasks, rebuilt from the same agent recordings with this branch, with
`fireworks_ai/gpt-oss-120b` and `fireworks_ai/glm-5p3-flash`:
| Application | Main (gpt-oss, GLM) | This branch (gpt-oss, GLM) |
| ---------------------------------------------- | ------------------- | -------------------------- |
| EspoCRM contacts: create, update email, delete | 0/3, 0/3 | 1/3, 1/3 (create) |
| Paperless tags: create, rename, delete | 1/3, 1/3 | 1/3, 2/3 (rename with GLM) |
- gpt-oss's rename did not apply. Its trajectory was not kept, so the cause is unconfirmed. A served `PUT` requires
every member the recorded call sent (about 14 here), and GLM sent them all.
- EspoCRM's served update accepts only the members its recording edited, so the email change is refused, as on main.
+18 -2
View File
@@ -746,7 +746,16 @@ integer of 10 or more digits that no entity of the type holds (where every one
holds a distinct integer of the same length, as time-based ids do), a
`nested-clock` object whose clocks the request stamped, a `template` around
a generated value, a `reference` to an entity, or a `composed` string; other
fields keep the observed value. A `reference` is an object holding fields of the
fields keep the observed value. When the first read of a created entity before
the next write shows fields the create's answer lacks, or its clocks to another
precision, the observed entity takes them (`observation.read`). An item read
that agrees with the answer otherwise lends its fields and clock spellings and
no longer confirms the create; one that disagrees or omits an answer field its
item view renders still checks it. Clock agreement uses the coarser recorded
precision, including microseconds. A list read
only adds fields the answer lacks and still checks it. Serving answers a create
in its answer's shape and clock precision and stores the fields of both with
the item read-back's clock precision. A `reference` is an object holding fields of the
entity a sent member names, as the state holds it (a parent beside `parentId`):
the member is accepted with any value, serving looks the entity up and refuses a
call naming one the environment does not hold or holds in more than one
@@ -778,7 +787,14 @@ with them. Other clocks the plan stamps take that spelling too unless their own
value shows a fixed width. A body that wraps one
object in a one-element array is that object, wrapped so on every call
(`body.envelope`). An update's members
echo when stored as sent; a member stored differently is accepted only with its
echo when stored as sent. A clock sent back as the entity held it and stored as
a fresh clock near the call is a `clock`, and a member stored as the slug of
another member stored as sent is a `slug`, both accepted with any value, as a
whole-object write that sends what it read shows. A save that echoes an
already-derived slug or holds a clock in its own second remains compatible
with that derivation in either call order; earlier saves must also reproduce
a newly learned slug. Any other member stored
differently is accepted only with its
recorded value (`body.fixed`) and stores the value the call that showed it
recorded (the `value` of its `constant` rule), and a field it changed without
sending must be a clock, a nested clock, an increment, a slug, a fresh `unique`
@@ -10,6 +10,12 @@ import {
referencedCreateRows,
shelvedRows,
} from "../../../tests/fixtures/environmentStateDerivedValues.js";
import {
LISTED_LABEL_ROWS,
READ_BACK_TAG_ROWS,
RESTAMPED_ROWS,
STALE_SLUG_ROWS,
} from "../../../tests/fixtures/environmentStateWriteViews.js";
import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
import {
bookClock,
@@ -180,3 +186,72 @@ describe("served objects that repeat a named entity, and sorted arrays", () => {
).toEqual(["fog", "ice", "wind"]);
});
});
describe("served writes whose answers show the entity otherwise than reads do", () => {
it("answers a create as the application answered it and holds the fields its read-back showed", () => {
const tags = servedFrom(READ_BACK_TAG_ROWS);
const created = objectOf(
tags.answer("post_tags", { body: { name: "Saltmarsh Gate" } }).body,
);
expect(Object.keys(created).sort()).toEqual(
["created", "id", "name", "slug"].sort(),
);
expect(created).toMatchObject({
name: "Saltmarsh Gate",
slug: "saltmarsh-gate",
created: firstServedClock(READ_BACK_TAG_ROWS).replace("Z", ".000000000Z"),
});
expect(
tags.answer("get_tags_by_id", { id: String(created["id"]) }).body,
).toMatchObject({
name: "Saltmarsh Gate",
document_count: 0,
created: firstServedClock(READ_BACK_TAG_ROWS),
});
});
it("lists a created label with the count its list read showed", () => {
const labels = servedFrom(LISTED_LABEL_ROWS);
labels.answer("post_labels", { body: { title: "Slipway" } });
expect((labels.answer("get_labels").body as JsonValue[]).at(-1)).toEqual({
id: 3,
title: "Slipway",
count: { posts: 0 },
});
});
it("slugs a renamed tag anew whatever slug the call sends back", () => {
const tags = servedFrom(STALE_SLUG_ROWS);
const renamed = objectOf(
tags.answer("put_tags_by_id", {
id: "2",
body: {
id: 2,
slug: "tide-tables",
name: "Neap Tide Tables",
document_count: 0,
created: "2026-10-01T10:00:00Z",
},
}).body,
);
expect(renamed).toMatchObject({
name: "Neap Tide Tables",
slug: "neap-tide-tables",
});
});
it("stamps a post's modification time anew rather than storing the one the call sends back", () => {
const posts = servedFrom(RESTAMPED_ROWS);
const edited = objectOf(
posts.answer("put_posts_by_id", {
id: "1",
body: { id: 1, title: "Winter rota v4", updated_at: bookClock(4) },
}).body,
);
expect(edited["title"]).toBe("Winter rota v4");
expect(edited["updated_at"]).not.toBe(bookClock(4));
expect(Date.parse(String(edited["updated_at"]))).toBeGreaterThan(
Date.parse(bookClock(4)),
);
});
});
@@ -29,6 +29,7 @@ import {
type StateWritePlan,
type WriteCall,
} from "../../domain/analysisView/environmentStateWriteRules.js";
import { withRead } from "../../domain/analysisView/environmentStateWriteObserve.js";
import {
formatClock,
generatedIntegerDigits,
@@ -139,17 +140,30 @@ const callBody = ({
: err(unloadable(tool, `Member ${member}`));
};
/** The entity a recorded observation shows: the values a create keeps and an update's constants. */
/** The object a recorded response shows at a pointer. */
const recordedObject = (
serving: StateServing,
at: { readonly ordinal: number; readonly pointer: string },
): JsonObject | undefined => {
const exchange = serving.source.exchanges.get(at.ordinal);
const body = exchange === undefined ? undefined : recordedJson(exchange);
const object = body === undefined ? undefined : valueAt(body, at.pointer);
return isObject(object) ? object : undefined;
};
/** The entity a recorded observation shows, with a create's read-back over it: the values a create keeps and an update's constants. */
export const observedEntity = (
serving: StateServing,
plan: StateWritePlan,
): JsonObject | undefined => {
if (plan.observation === null) return undefined;
const exchange = serving.source.exchanges.get(plan.observation.ordinal);
const body = exchange === undefined ? undefined : recordedJson(exchange);
const object =
body === undefined ? undefined : valueAt(body, plan.observation.pointer);
return isObject(object) ? object : undefined;
const object = recordedObject(serving, plan.observation);
if (object === undefined || plan.observation.read === undefined)
return object;
const read = recordedObject(serving, plan.observation.read);
return read === undefined
? undefined
: withRead(object, read, plan.observation.read.shape);
};
/** One past the largest integer these entities hold in the field, read as the exemplar spells it, or undefined when none holds one. */
@@ -347,7 +361,15 @@ export const writeAnswer = (
: writtenResponse(
plan.response,
recorded,
rendered,
{
...rendered,
clocks:
plan.observation?.read?.shape === "item"
? plan.rules.flatMap((rule) =>
rule.rule === "clock" ? [rule.field] : [],
)
: [],
},
optionalFields(plan),
);
if (!body.ok) return err(refused(tool, body.error));
@@ -350,7 +350,20 @@ const writePlanSchema = z.strictObject({
.nullable()
.describe("The first successful recorded call the plan was built from."),
observation: z
.strictObject({ ordinal: ordinalSchema, pointer: z.string() })
.strictObject({
ordinal: ordinalSchema,
pointer: z.string(),
read: z
.strictObject({
ordinal: ordinalSchema,
pointer: z.string(),
shape: z.enum(["item", "list"]),
})
.exactOptional()
.describe(
"A create's later read of the entity: an item read lends fields the response lacks and its clock spellings, a list read only adds fields the response lacks.",
),
})
.nullable()
.describe(
"The record whose response showed the written entity or document, and a JSON pointer to it.",
@@ -33,6 +33,7 @@ import {
echoed,
integerOf,
readClock,
respellClock,
isUuid,
replaceDelimited,
slugOf,
@@ -488,7 +489,12 @@ export const optionalFields = (
export const writtenResponse = (
response: WriteResponse,
recorded: JsonValue | undefined,
written: { readonly fields: JsonObject | undefined; readonly id: string },
written: {
readonly fields: JsonObject | undefined;
readonly id: string;
/** Generated clocks keep the response's spelling independently of the stored entity's. */
readonly clocks?: readonly string[];
},
optional: readonly string[] = [],
): Applied<JsonValue | undefined> => {
if (response.shape === "empty") return ok(undefined);
@@ -525,7 +531,17 @@ export const writtenResponse = (
recorded,
pointer,
Object.fromEntries(
fields.map((field) => [field, written.fields![field]!]),
fields.map((field) => {
const value = written.fields![field]!;
const clock =
written.clocks?.includes(field) === true
? readClock(shown[field])
: undefined;
return [
field,
clock === undefined ? value : respellClock(value, clock.format),
];
}),
),
),
);
@@ -104,7 +104,35 @@ export interface UpdateCall {
readonly kept: readonly string[];
}
/** 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. */
/** Ambiguous saves may echo an already-derived slug or hold a clock in its own second. Keep the demonstrated derivation; call evidence still has to reproduce it. */
export const reconciledUpdateRule = (
held: FieldRule,
later: FieldRule,
kept: ReadonlySet<string>,
): FieldRule | undefined => {
const alike = alikeRules(held, later, kept);
if (alike !== undefined) return alike;
for (const [derived, ambiguous] of [
[held, later],
[later, held],
] as const) {
if (
derived.rule === "slug" &&
ambiguous.rule === "echo" &&
ambiguous.sorted !== true
)
return derived;
if (
derived.rule === "clock" &&
ambiguous.rule === "held-clock" &&
same(derived.format, ambiguous.format)
)
return derived;
}
return undefined;
};
/** Whether a later rule reproduces earlier calls, including echoes promoted to derivations. */
export const contradictsEarlierCalls = (
draft: {
readonly plan: { readonly rules: readonly FieldRule[] };
@@ -112,11 +140,12 @@ export const contradictsEarlierCalls = (
},
rules: readonly FieldRule[],
): boolean => {
const ruled = new Set(draft.plan.rules.map((rule) => rule.field));
const ruled = new Map(draft.plan.rules.map((rule) => [rule.field, rule]));
return rules.some((rule) => {
if (
!(unsentChange(rule) || regenerates(rule) || rule.rule === "clock") ||
ruled.has(rule.field)
(ruled.has(rule.field) &&
!(rule.rule === "slug" && ruled.get(rule.field)?.rule === "echo"))
)
return false;
// Compute a structured rule's digest once, rather than once per earlier call.
@@ -148,9 +177,12 @@ export const contradictsEarlierCalls = (
);
}
const from = rule.rule === "slug" ? sent.get(rule.from) : undefined;
// A slug rule runs only when its source is sent; otherwise serving keeps the field.
return (
typeof from === "string" &&
!sameObserved(observation, observedValue(slugOf(from)))
rule.rule === "slug" &&
(typeof from === "string"
? !sameObserved(observation, observedValue(slugOf(from)))
: !kept.includes(rule.field))
);
});
});
@@ -205,7 +237,10 @@ export const conflictsWithPlan = (
(identity !== null && fixed.has(identity)) ||
explained.rules.some((rule) => {
const held = rules.get(rule.field);
return held !== undefined && alikeRules(held, rule, kept) === undefined;
return (
held !== undefined &&
reconciledUpdateRule(held, rule, kept) === undefined
);
})
);
};
@@ -9,6 +9,7 @@ import type { FieldRule, WriteCall } from "./environmentStateWriteRules.js";
import {
integerOf,
readClock,
slugOf,
sortedStrings,
} from "./environmentStateWriteValues.js";
import { freshUuid, renewedRule } from "./environmentStateWriteGenerated.js";
@@ -139,7 +140,35 @@ export const sameSecondClocks = (evidence: UpdateEvidence): string[] =>
inCallSecond(evidence.after[field], evidence.startMs),
);
/** How an update stored a sent member: stamped anew as the one held clock it sent back other than a creation clock, echoed, renewed as a fresh UUID, or as a constant. */
/** Another sent member stored as sent whose slug the application stored in place of what this member sent. */
const sentSlugSource = (
evidence: UpdateEvidence,
member: string,
): string | undefined => {
const stored = evidence.after[member];
if (typeof stored !== "string") return undefined;
return Object.entries(evidence.call.body).find(
([other, value]) =>
other !== member &&
typeof value === "string" &&
same(evidence.after[other], value) &&
slugOf(value) === stored,
)?.[0];
};
/** Whether a call sent back the clock the entity held, as a whole-object write does, and the application stamped it anew near the call. */
const restampedClock = (
evidence: UpdateEvidence,
member: string,
sent: JsonValue,
): boolean =>
!CREATION_CLOCK.test(member) &&
readClock(sent) !== undefined &&
same(evidence.before[member], sent) &&
readClock(evidence.after[member]) !== undefined &&
isClockNear(evidence.after[member]!, evidence.startMs);
/** How an update stored a sent member: stamped anew as the one held clock it sent back other than a creation clock, echoed, stamped anew or slugged from another member in place of what it sent, renewed as a fresh UUID, or as a constant. */
export const sentRule = (
evidence: UpdateEvidence,
member: string,
@@ -156,6 +185,14 @@ export const sentRule = (
return { field: member, rule: "echo" };
if (same(evidence.after[member], sortedStrings(sent)))
return { field: member, rule: "echo", sorted: true };
if (restampedClock(evidence, member, sent))
return {
field: member,
rule: "clock",
format: readClock(evidence.after[member])!.format,
};
const from = sentSlugSource(evidence, member);
if (from !== undefined) return { field: member, rule: "slug", from };
return freshUuid(evidence, member)
? { field: member, rule: "unique" }
: { field: member, rule: "constant", value: evidence.after[member]! };
@@ -61,6 +61,8 @@ import {
import {
laterObservation,
newEntityIn,
respelledInstant,
withRead,
type Observed,
} from "./environmentStateWriteObserve.js";
import {
@@ -97,6 +99,57 @@ const respondedId = (
return members.length === 1 ? scalarText(members[0]) : undefined;
};
/**
* A create's answer with the first read of its entity before the next write over it, when that read shows fields the
* answer lacks or spells its clocks to another precision: the state holds what reads show. An item read that agrees
* with the answer otherwise lends it its fields and then observes rather than checks; one that disagrees still checks
* the create. A list read, which may show fewer keys of an object, only adds fields the answer lacks and still checks.
*/
const withReadBack = (
context: EffectContext,
step: WriteStep,
target: {
readonly type: string;
readonly collection: string;
readonly idField: string;
},
own: Observed,
): Observed => {
const later = laterObservation(searchFrom(context, step), {
type: target.type,
collection: target.collection,
idField: target.idField,
id: own.id,
held: () => false,
isWrite: context.isWrite,
});
if (later === undefined) return own;
const view = context.viewOf(later.ordinal);
if (
view?.shape === "item" &&
(view.fields.some(
(field) =>
Object.hasOwn(own.object, field) && !Object.hasOwn(later.object, field),
) ||
Object.entries(later.object).some(
([field, value]) =>
Object.hasOwn(own.object, field) &&
!same(own.object[field], value) &&
!respelledInstant(own.object[field], value),
))
)
return own;
const shape = view?.shape === "item" ? "item" : "list";
const object = withRead(own.object, later.object, shape);
if (same(object, own.object)) return own;
if (shape === "item") context.observed(later.ordinal);
return {
...own,
object,
read: { ordinal: later.ordinal, pointer: later.pointer, shape },
};
};
/**
* The written entity a create's response or a later read shows. A linked create's read must name an
* id its response returned, as the read that linked it did, envelopes included.
@@ -116,7 +169,7 @@ const createdObservation = (
const own = context.io.withBody(step.record.ordinal, (body) =>
newEntityIn(step.record.ordinal, body, target.idField, held),
);
if (own !== undefined) return own;
if (own !== undefined) return withReadBack(context, step, target, own);
const { id, ids } = context.io.withBody(step.record.ordinal, (body) => {
const returned = target.linked ? respondedIds(body) : [];
// A response that names the created id alone ties the later read to that id.
@@ -320,6 +373,9 @@ const buildCreate = (
observation: {
ordinal: evidence.observed.ordinal,
pointer: evidence.observed.pointer,
...(evidence.observed.read === undefined
? {}
: { read: evidence.observed.read }),
},
response: context.io.withBody(step.record.ordinal, (body) =>
responseShapeOf(
@@ -39,6 +39,7 @@ import {
type UpdateCall,
conflictsWithPlan,
refinedBody,
reconciledUpdateRule,
} from "./environmentStateWriteCalls.js";
import {
laterObservation,
@@ -58,7 +59,6 @@ import {
observationNeeds,
} from "./environmentStateWriteAddress.js";
import {
alikeRules,
keptOrders,
pathValuesOf,
siblingTrimmedClocks,
@@ -394,7 +394,7 @@ const refineUpdate = (
const shown = later.get(rule.field);
return shown === undefined
? rule
: (alikeRules(rule, shown, draft.kept) ?? rule);
: (reconciledUpdateRule(rule, shown, draft.kept) ?? rule);
}),
...evidence.explained.rules.filter((rule) => !ruled.has(rule.field)),
];
@@ -703,7 +703,11 @@ const contradictsGeneratedClock = (
if (
rule.rule !== "clock" ||
!Object.hasOwn(after, rule.field) ||
explained.unstamped.includes(rule.field)
explained.unstamped.includes(rule.field) ||
explained.rules.some(
(candidate) =>
candidate.field === rule.field && candidate.rule === "held-clock",
)
)
return false;
const previous = readClock(before[rule.field]);
@@ -15,6 +15,41 @@ import {
type StateView,
} from "./environmentStateRules.js";
import { queryValues, replayReason } from "./environmentStateVariants.js";
import { readClock } from "./environmentStateWriteValues.js";
/** A read a create's observation takes fields from: an item read's respell the response's clocks and add fields, a list read's only add fields. */
export interface ObservedRead {
readonly ordinal: number;
readonly pointer: string;
readonly shape: "item" | "list";
}
/** Whether two clocks agree at the coarser recorded fraction precision, retaining all digits through nanoseconds. */
export const respelledInstant = (
left: JsonValue | undefined,
right: JsonValue | undefined,
): boolean => {
const [a, b] = [readClock(left), readClock(right)];
if (a?.format.kind !== "iso" || b?.format.kind !== "iso") return false;
const [fine, coarse] =
a.format.fraction >= b.format.fraction ? [a, b] : [b, a];
if (coarse.format.kind !== "iso") return false;
const unit = 10 ** (9 - coarse.format.fraction);
const fraction = (clock: typeof fine) =>
(((clock.ms % 1000) + 1000) % 1000) * 1_000_000 + clock.subMillisecond;
return (
Math.floor(fine.ms / 1000) === Math.floor(coarse.ms / 1000) &&
Math.floor(fraction(fine) / unit) * unit === fraction(coarse)
);
};
/** A create's response object with a read's fields: an item read's values win, a list read only adds fields the response lacks. */
export const withRead = (
response: JsonObject,
read: JsonObject,
shape: ObservedRead["shape"],
): JsonObject =>
shape === "item" ? { ...response, ...read } : { ...read, ...response };
/** Where an observed entity object was found: the record and a JSON pointer into its response. */
export interface Observed {
@@ -22,6 +57,8 @@ export interface Observed {
readonly pointer: string;
readonly object: JsonObject;
readonly id: string;
/** A create's later read whose fields the object adds to the response's, and whose clock spellings an item read lends it. */
readonly read?: ObservedRead;
}
/** Objects a response carries where an entity may be, in precedence tiers: the root, one member, then the element of a one-element array at the root or in one member. */
@@ -253,10 +253,15 @@ export interface StateWritePlan {
readonly served: boolean;
readonly reason: WriteReason | null;
readonly exemplar_ordinal: number | null;
/** The record whose response showed the written entity or document, and where. */
/** The record whose response showed the written entity or document, and where; a create's later read whose fields the entity adds to the response's. */
readonly observation: {
readonly ordinal: number;
readonly pointer: string;
readonly read?: {
readonly ordinal: number;
readonly pointer: string;
readonly shape: "item" | "list";
};
} | null;
readonly response: WriteResponse | null;
readonly body: WriteBody | null;
@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../jsonValue.js";
import {
timedCaptureEvidence,
type TimedCaptureRow,
} from "../../../tests/fixtures/analysisView.js";
import {
LISTED_LABEL_ROWS,
READ_BACK_TAG_ROWS,
RESTAMPED_ROWS,
STALE_SLUG_ROWS,
clockSaveRows,
slugSaveRows,
slugOnlySaveRows,
} from "../../../tests/fixtures/environmentStateWriteViews.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("creates whose answer shows the entity otherwise than reads do", () => {
it.each(["123456", "123457"])(
"checks read-back precision beyond milliseconds (%s)",
(fraction) => {
const rows = READ_BACK_TAG_ROWS.map(
([method, path, body = null, ...rest]): TimedCaptureRow => {
const rewrite = (value: JsonValue): JsonValue => {
if (Array.isArray(value)) return value.map(rewrite);
if (value !== null && typeof value === "object")
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
key === "created" && typeof child === "string"
? child.replace(
/(?:\.\d+)?Z$/u,
method === "POST" ? ".123456000Z" : `.${fraction}Z`,
)
: child,
]),
);
return value;
};
return [method, path, rewrite(body), ...rest];
},
);
expect(planOf(rows, "post_tags").served).toBe(fraction === "123456");
},
);
it("keeps an item read's omission as contradictory evidence even when a later list confirms the added fields", () => {
const rows = READ_BACK_TAG_ROWS.map(
([method, path, body = null, ...rest]): TimedCaptureRow => [
method,
path,
body !== null &&
typeof body === "object" &&
!Array.isArray(body) &&
(method === "POST" || path === "/api/tags/1" || path === "/api/tags/2")
? { ...body, detail: "Only in the answer" }
: body,
...rest,
],
);
expect(planOf(rows, "post_tags")).toMatchObject({
served: false,
reason: "contradicted",
});
});
it("serves a create whose item read adds a count and spells the clock otherwise, taking the read's fields", () => {
expect(planOf(READ_BACK_TAG_ROWS, "post_tags")).toMatchObject({
served: true,
observation: {
ordinal: 3,
pointer: "",
read: { ordinal: 4, pointer: "", shape: "item" },
},
rules: expect.arrayContaining([
{
field: "created",
rule: "clock",
format: expect.objectContaining({ fraction: 0 }),
},
]),
});
});
it("serves a create whose list read adds a count the answer lacks", () => {
expect(planOf(LISTED_LABEL_ROWS, "post_labels")).toMatchObject({
served: true,
observation: { read: { ordinal: 2, pointer: "/2", shape: "list" } },
});
});
});
describe("whole-object updates that send back what the application derives", () => {
it.each([
[true, true],
[false, true],
[true, false],
[false, false],
] as const)(
"checks slug-only saves too (derived first: %s, changed: %s)",
(derivedFirst, changed) => {
expect(
planOf(slugOnlySaveRows(derivedFirst, changed), "patch_tags_by_id")
.served,
).toBe(!changed);
},
);
it.each([true, false])(
"reconciles slug saves in either order (derived first: %s)",
(derivedFirst) => {
expect(
planOf(slugSaveRows(derivedFirst), "put_tags_by_id"),
).toMatchObject({
served: true,
rules: expect.arrayContaining([
{ field: "slug", rule: "slug", from: "name" },
]),
});
},
);
it.each([true, false])(
"rejects a slug rule contradicted by an explicit custom slug (derived first: %s)",
(derivedFirst) => {
expect(
planOf(slugSaveRows(derivedFirst, true), "put_tags_by_id").served,
).toBe(false);
},
);
it.each([true, false])(
"reconciles held and fresh clocks in either order (derived first: %s)",
(derivedFirst) => {
expect(
planOf(clockSaveRows(derivedFirst), "put_posts_by_id"),
).toMatchObject({
served: true,
rules: expect.arrayContaining([
expect.objectContaining({ field: "updated_at", rule: "clock" }),
]),
});
},
);
it("slugs a sent slug anew from the name the call stored", () => {
expect(planOf(STALE_SLUG_ROWS, "put_tags_by_id")).toMatchObject({
served: true,
rules: expect.arrayContaining([
{ field: "slug", rule: "slug", from: "name" },
{ field: "name", rule: "echo" },
]),
body: { accepted: expect.arrayContaining(["slug", "name"]) },
});
});
it("stamps anew a modification time each call sends back as it read it", () => {
expect(planOf(RESTAMPED_ROWS, "put_posts_by_id")).toMatchObject({
served: true,
rules: expect.arrayContaining([
expect.objectContaining({ field: "updated_at", rule: "clock" }),
]),
body: { accepted: expect.arrayContaining(["updated_at"]) },
});
});
});
@@ -24,6 +24,10 @@ import {
renamedBook,
} from "../../fixtures/environmentStateWrites.js";
import { referencedCreateRows } from "../../fixtures/environmentStateDerivedValues.js";
import {
READ_BACK_TAG_ROWS,
slugOnlySaveRows,
} from "../../fixtures/environmentStateWriteViews.js";
import { unsupportedInputLiteralSchemaError } from "../../fixtures/mcpClientSchemaConstraints.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { startListeningProcess } from "../../support/mcp/streamableHttpProcess.js";
@@ -34,6 +38,66 @@ const HTTP_BOUNDARY_TIMEOUT_MS = 90_000;
/** The book the first served create of "Boundary Book" makes: one second past the served clock's start. */
const BOUNDARY_BOOK = book(3, "Boundary Book", bookClock(13));
processTest(
"keeps create response clocks separate from enriched item reads through the SDK",
async ({ processes }) => {
const rows = READ_BACK_TAG_ROWS;
const root = await createTestTempDirectory("rea-write-views-sdk-");
const files = await writeEnvironmentServeFiles(
root,
environmentStateServeFixture(timedCaptureEvidence(rows)),
);
const started = await startListeningProcess(processes, [
"environment-serve",
files.manifestPath,
"--evidence",
files.capturePath,
"--port",
"0",
]);
const client = new Client({ name: "write-views", version: "1" });
await client.connect(new StreamableHTTPClientTransport(started.url));
try {
const { tools } = await client.listTools();
expect(tools.map(({ name }) => name)).toEqual(
expect.arrayContaining(["post_tags", "get_tags_by_id"]),
);
const created = await client.callTool({
name: "post_tags",
arguments: { body: { name: "Boundary Tag" } },
});
expect(created.isError).toBeFalsy();
const clock = bookClock(rows.length + 1);
expect(created.structuredContent).toMatchObject({
body: {
id: 3,
name: "Boundary Tag",
created: clock.replace("Z", ".000000000Z"),
},
state: { source: "state" },
});
expect(created.structuredContent).not.toHaveProperty(
"body.document_count",
);
const read = await client.callTool({
name: "get_tags_by_id",
arguments: { id: "3" },
});
expect(read.structuredContent).toMatchObject({
body: {
id: 3,
name: "Boundary Tag",
created: clock,
document_count: 0,
},
});
} finally {
await client.close();
}
},
HTTP_BOUNDARY_TIMEOUT_MS,
);
processTest(
"discovers empty fixed values and applies omitted recorded queries through the SDK before enforcing writes",
async ({ processes }) => {
@@ -411,15 +475,28 @@ processTest(
HTTP_BOUNDARY_TIMEOUT_MS,
);
processTest(
"withholds a create from public tools when its sent reference contradicts the recorded response",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-create-reference-mcp-");
const rows = referencedCreateRows({
processTest.for([
{
label: "a create whose sent reference contradicts its response",
rows: referencedCreateRows({
id: 1,
name: "Different Hall",
floor: "ground",
});
}),
read: "get_books",
write: "post_books",
},
...[true, false].map((derivedFirst) => ({
label: `a slug-only edit incompatible with a derived save (derived first: ${String(derivedFirst)})`,
rows: slugOnlySaveRows(derivedFirst, true),
read: "get_tags",
write: "patch_tags_by_id",
})),
])(
"withholds $label from public tools",
{ timeout: HTTP_BOUNDARY_TIMEOUT_MS },
async ({ rows, read, write }, { processes }) => {
const root = await createTestTempDirectory("rea-create-reference-mcp-");
const files = await writeEnvironmentServeFiles(
root,
environmentStateServeFixture(timedCaptureEvidence(rows)),
@@ -439,11 +516,10 @@ processTest(
await client.connect(new StreamableHTTPClientTransport(started.url));
try {
const { tools } = await client.listTools();
expect(tools.map(({ name }) => name)).toContain("get_books");
expect(tools.map(({ name }) => name)).not.toContain("post_books");
expect(tools.map(({ name }) => name)).toContain(read);
expect(tools.map(({ name }) => name)).not.toContain(write);
} finally {
await client.close();
}
},
HTTP_BOUNDARY_TIMEOUT_MS,
);
+292
View File
@@ -0,0 +1,292 @@
import type { TimedCaptureRow } from "./analysisView.js";
import { bookClock } from "./environmentStateWrites.js";
const tag = (id: number, name: string, slug: string, created: string) => ({
id,
slug,
name,
document_count: id === 1 ? 2 : 0,
created,
});
const SEED_TAGS = [
tag(1, "Lamp Oil", "lamp-oil", "2026-10-01T09:00:00Z"),
tag(2, "Tide Tables", "tide-tables", "2026-10-01T10:00:00Z"),
];
/** A tag as a create answers it: without the document count reads show, and with its clock to the nanosecond. */
const answered = (id: number, name: string, slug: string, at: number) => ({
id,
slug,
name,
created: bookClock(at).replace("Z", ".123456789Z"),
});
/** Two tag creates whose answers lack the document count, and spell the clock otherwise, than the item read after each. */
export const READ_BACK_TAG_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/tags", SEED_TAGS],
["GET", "/api/tags/1", SEED_TAGS[0]!],
["GET", "/api/tags/2", SEED_TAGS[1]!],
[
"POST",
"/api/tags",
answered(3, "Harbour Notices", "harbour-notices", 3),
201,
{ name: "Harbour Notices" },
],
[
"GET",
"/api/tags/3",
tag(3, "Harbour Notices", "harbour-notices", bookClock(3)),
],
[
"GET",
"/api/tags",
[...SEED_TAGS, tag(3, "Harbour Notices", "harbour-notices", bookClock(3))],
],
[
"POST",
"/api/tags",
answered(4, "Quarry Notes", "quarry-notes", 6),
201,
{ name: "Quarry Notes" },
],
["GET", "/api/tags/4", tag(4, "Quarry Notes", "quarry-notes", bookClock(6))],
[
"GET",
"/api/tags",
[
...SEED_TAGS,
tag(3, "Harbour Notices", "harbour-notices", bookClock(3)),
tag(4, "Quarry Notes", "quarry-notes", bookClock(6)),
],
],
];
const label = (id: number, title: string, posts: number) => ({
id,
title,
count: { posts },
});
/** Two label creates whose answers lack the post count only the list shows; no item read follows them. */
export const LISTED_LABEL_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/labels", [label(1, "Ferry", 3), label(2, "Pier", 1)]],
[
"POST",
"/api/labels",
{ id: 3, title: "Mooring" },
201,
{ title: "Mooring" },
],
[
"GET",
"/api/labels",
[label(1, "Ferry", 3), label(2, "Pier", 1), label(3, "Mooring", 0)],
],
["POST", "/api/labels", { id: 4, title: "Buoy" }, 201, { title: "Buoy" }],
[
"GET",
"/api/labels",
[
label(1, "Ferry", 3),
label(2, "Pier", 1),
label(3, "Mooring", 0),
label(4, "Buoy", 0),
],
],
];
/** Two renames that send the tag whole, its old slug included, which the application slugs anew from the name. */
export const STALE_SLUG_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/tags", SEED_TAGS],
["GET", "/api/tags/1", SEED_TAGS[0]!],
["GET", "/api/tags/2", SEED_TAGS[1]!],
[
"PUT",
"/api/tags/1",
{
...SEED_TAGS[0]!,
name: "Lamp Oil Deliveries",
slug: "lamp-oil-deliveries",
},
200,
{ ...SEED_TAGS[0]!, name: "Lamp Oil Deliveries" },
],
[
"GET",
"/api/tags/1",
{
...SEED_TAGS[0]!,
name: "Lamp Oil Deliveries",
slug: "lamp-oil-deliveries",
},
],
[
"PUT",
"/api/tags/2",
{
...SEED_TAGS[1]!,
name: "Spring Tide Tables",
slug: "spring-tide-tables",
},
200,
{ ...SEED_TAGS[1]!, name: "Spring Tide Tables" },
],
[
"GET",
"/api/tags/2",
{
...SEED_TAGS[1]!,
name: "Spring Tide Tables",
slug: "spring-tide-tables",
},
],
[
"GET",
"/api/tags",
[
{
...SEED_TAGS[0]!,
name: "Lamp Oil Deliveries",
slug: "lamp-oil-deliveries",
},
{
...SEED_TAGS[1]!,
name: "Spring Tide Tables",
slug: "spring-tide-tables",
},
],
],
];
const POST = {
id: 1,
title: "Winter rota",
updated_at: "2026-10-01T09:00:00Z",
};
/** Two edits that send back the modification time they read, which the application stamps anew. */
export const RESTAMPED_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/posts", [POST]],
["GET", "/api/posts/1", POST],
[
"PUT",
"/api/posts/1",
{ ...POST, title: "Winter rota v2", updated_at: bookClock(2) },
200,
{ ...POST, title: "Winter rota v2" },
],
[
"GET",
"/api/posts/1",
{ ...POST, title: "Winter rota v2", updated_at: bookClock(2) },
],
[
"PUT",
"/api/posts/1",
{ ...POST, title: "Winter rota v3", updated_at: bookClock(4) },
200,
{ ...POST, title: "Winter rota v3", updated_at: bookClock(2) },
],
[
"GET",
"/api/posts/1",
{ ...POST, title: "Winter rota v3", updated_at: bookClock(4) },
],
[
"GET",
"/api/posts",
[{ ...POST, title: "Winter rota v3", updated_at: bookClock(4) }],
],
];
/** One save leaves its slug as sent; the other demonstrates regeneration, in either recording order. */
export const slugSaveRows = (
derivedFirst: boolean,
customSlug = false,
): readonly TimedCaptureRow[] => {
const saved = { ...SEED_TAGS[0]!, ...(customSlug ? { slug: "custom" } : {}) };
const renamed = {
...SEED_TAGS[1]!,
name: "Spring Tide Tables",
slug: "spring-tide-tables",
};
const saves: TimedCaptureRow[][] = [
[
["PUT", "/api/tags/1", saved, 200, saved],
["GET", "/api/tags/1", saved],
],
[
[
"PUT",
"/api/tags/2",
renamed,
200,
{ ...SEED_TAGS[1]!, name: renamed.name },
],
["GET", "/api/tags/2", renamed],
],
];
return [
...STALE_SLUG_ROWS.slice(0, 3),
...saves[derivedFirst ? 1 : 0]!,
...saves[derivedFirst ? 0 : 1]!,
["GET", "/api/tags", [saved, renamed]],
];
};
/** A slug-only PATCH and a save that demonstrates deriving the slug from the name. */
export const slugOnlySaveRows = (
derivedFirst: boolean,
changed: boolean,
): readonly TimedCaptureRow[] =>
slugSaveRows(derivedFirst, changed).map(
([method, path, body = null, status, request, ...rest]): TimedCaptureRow =>
method === "PUT"
? [
"PATCH",
path,
body,
status ?? 200,
path === "/api/tags/1"
? { slug: changed ? "custom" : "lamp-oil" }
: request,
...rest,
]
: [method, path, body, status ?? 200, request, ...rest],
);
/** One whole-object save falls in its held clock's second; the other visibly stamps a fresh clock. */
export const clockSaveRows = (
derivedFirst: boolean,
): readonly TimedCaptureRow[] => {
const heldIndex = derivedFirst ? 5 : 3;
const freshIndex = derivedFirst ? 3 : 5;
const held = { ...POST, updated_at: bookClock(heldIndex) };
const fresh = { ...POST, id: 2 };
const saved = { ...held, title: "Same-second save" };
const stamped = {
...fresh,
title: "Fresh save",
updated_at: bookClock(freshIndex),
};
const saves: TimedCaptureRow[][] = [
[
["PUT", "/api/posts/1", saved, 200, saved],
["GET", "/api/posts/1", saved],
],
[
["PUT", "/api/posts/2", stamped, 200, { ...fresh, title: stamped.title }],
["GET", "/api/posts/2", stamped],
],
];
return [
["GET", "/api/posts", [held, fresh]],
["GET", "/api/posts/1", held],
["GET", "/api/posts/2", fresh],
...saves[derivedFirst ? 1 : 0]!,
...saves[derivedFirst ? 0 : 1]!,
["GET", "/api/posts", [saved, stamped]],
];
};