fix(environment): share write overlap and check clocks on earlier calls

Observation search skips a read begun before the write ended. That
comparison now uses the same overlap as the check path.

A clock learned on a later call was not checked against earlier calls
that kept the field. Serving would stamp those calls too, so the plan
stays unserved. A clock learned earlier is still refused when a later
call does not advance it.

Regression tests cover an overlapping empty update and a published_at
clock that one recorded call leaves unchanged.

Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
Cursor Agentandmorluto committed 2026-10-21 23:07:16 +00:00
1 parent cddd3e624b
commit fb6785dcaf
4 files changed
+129 -4

No files matched your search

@@ -277,6 +277,97 @@ describe("slug rules learned from a later call", () => {
});
});
describe("clock rules learned across calls", () => {
it("serves a clock only when every recorded call changed that field", () => {
const oldFirst = "2026-10-01T09:00:00Z";
const oldSecond = "2026-10-01T10:00:00Z";
const described = (
entry: Book,
description: string,
publishedAt: string,
): Book => ({ ...entry, description, published_at: publishedAt });
const first = described(FIRST_BOOK, "original", oldFirst);
const second = described(SECOND_BOOK, "other", oldSecond);
const published = described(
renamedBook(FIRST_BOOK, "Renamed Book", 3),
"original",
bookClock(3),
);
/** The first call stamps `published_at`; `later` is the second call's answer. */
const rows = (later: Book, sent: Book): TimedCaptureRow[] => [
["GET", "/api/books", [first, second]],
["GET", "/api/books/1", first],
["GET", "/api/books/2", second],
["PATCH", "/api/books/1", published, 200, { name: "Renamed Book" }],
["GET", "/api/books/1", published],
["PATCH", "/api/books/2", later, 200, sent],
["GET", "/api/books/2", later],
];
// The description edit stamps `updated_at` and leaves `published_at` as it was.
const edited = described(
{ ...SECOND_BOOK, updated_at: bookClock(5) },
"revised",
oldSecond,
);
expect(
gated(rows(edited, { description: "revised" }), "patch_books_by_id"),
).toMatchObject({ served: false, reason: "unexplained-change" });
const stamped = described(
renamedBook(SECOND_BOOK, "Second Edition", 5),
"other",
bookClock(5),
);
const plan = gated(
rows(stamped, { name: "Second Edition" }),
"patch_books_by_id",
);
expect(plan).toMatchObject({
served: true,
checked_record_ordinals: [4, 6],
});
expect(plan.rules).toContainEqual({
field: "published_at",
rule: "clock",
format: { kind: "iso", separator: "T", fraction: 0, zone: "Z" },
});
});
it("leaves a clock unserved when an earlier call kept the field a later call stamped", () => {
const oldFirst = "2026-10-01T09:00:00Z";
const oldSecond = "2026-10-01T10:00:00Z";
const described = (
entry: Book,
description: string,
publishedAt: string,
): Book => ({ ...entry, description, published_at: publishedAt });
const first = described(FIRST_BOOK, "original", oldFirst);
const second = described(SECOND_BOOK, "other", oldSecond);
const edited = described(
{ ...SECOND_BOOK, updated_at: bookClock(3) },
"revised",
oldSecond,
);
const published = described(
renamedBook(FIRST_BOOK, "Renamed Book", 5),
"original",
bookClock(5),
);
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [first, second]],
["GET", "/api/books/1", first],
["GET", "/api/books/2", second],
["PATCH", "/api/books/2", edited, 200, { description: "revised" }],
["GET", "/api/books/2", edited],
["PATCH", "/api/books/1", published, 200, { name: "Renamed Book" }],
["GET", "/api/books/1", published],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: false,
reason: "unexplained-change",
});
});
});
describe("checked lines", () => {
/** A list page that echoes its own request path, as some APIs do. */
const page = (path: string, items: JsonValue[]): JsonValue => ({
@@ -1057,6 +1148,30 @@ describe("what a check leaves pending", () => {
});
});
it("learns an update from a read begun after the write, not from one that overlapped it", () => {
const named = (name: string) => ({ id: 1, name });
const other = { id: 2, name: "B" };
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [named("A"), other]],
["GET", "/api/books/1", named("A")],
["GET", "/api/books/2", other],
// The patch runs from second 3 to 5.5 and returns no entity. A read begun while it still runs shows the old name.
["PATCH", "/api/books/1", "", 204, { name: "Z" }, 2500],
["GET", "/api/books/1", named("A")],
["GET", "/api/books/1", named("A")],
["GET", "/api/books/1", named("Z")],
["GET", "/api/books", [named("Z"), other]],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: true,
observation: { ordinal: 6, pointer: "" },
body: { accepted: ["name"], fixed: [] },
rules: [{ field: "name", rule: "echo" }],
checked_record_ordinals: [7],
contradicted_by: null,
});
});
it("checks nothing by a range read the collection has outgrown", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books?_start=0&_end=2", [FIRST_BOOK, SECOND_BOOK]],
@@ -19,12 +19,16 @@ export type PendingTargets = Map<
{ until: number; readonly at: Map<string, number> }
>;
/** Whether a write that ended at `until` was still running when a read began at `start`, so the read may show either state. */
export const overlapsWrite = (until: number, start: number): boolean =>
until > start;
/** Whether a modelled write to the target was still running when a read began at `start`, so the read may show either state. */
export const runningAt = (
pending: PendingTargets,
key: string,
start: number,
): boolean => (pending.get(key)?.until ?? -Infinity) > start;
): boolean => overlapsWrite(pending.get(key)?.until ?? -Infinity, start);
/** The tools whose writes a read exercises: those that changed its targets since they were last checked, less those that borrowed the read to learn their effect. */
export const exercisedBy = (
@@ -313,13 +313,15 @@ const contradictsEarlierCalls = (
const ruled = new Set(draft.plan.rules.map((rule) => rule.field));
return rules.some(
(rule) =>
unsentChange(rule) &&
(unsentChange(rule) || rule.rule === "clock") &&
!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;
// Serving stamps a clock on every call, so a field an earlier call kept contradicts it.
if (rule.rule === "clock") return true;
const from = rule.rule === "slug" ? sent.get(rule.from) : undefined;
return typeof from === "string" && slugOf(from) !== value;
}),
@@ -1,4 +1,5 @@
import type { JsonValue } from "../jsonValue.js";
import { overlapsWrite } from "./environmentStateWriteAttribution.js";
import {
collectionPathOf,
isObject,
@@ -62,7 +63,7 @@ export const newEntityIn = (
export interface ObservationSearch {
readonly records: readonly StateRecordFacts[];
readonly from: number;
/** A timed write's completion; reads begun before it cannot observe its effect. */
/** A timed write's completion. A read begun while it still ran may show the prior state. */
readonly after?: number;
readonly viewOf: (ordinal: number) => StateView | undefined;
readonly withBody: <T>(
@@ -77,6 +78,7 @@ const succeeded = (record: StateRecordFacts): boolean =>
/**
* The first later read, before the next write, that shows a written entity: an item read of
* `id` when given, else an item read of an unheld id or a collection read listing exactly one.
* A read begun while the write still ran may show the earlier state, so it is not chosen.
*/
export const laterObservation = (
search: ObservationSearch,
@@ -93,7 +95,9 @@ export const laterObservation = (
): Observed | undefined => {
for (const record of search.records.slice(search.from + 1)) {
if (target.isWrite(record)) return undefined;
if (search.after !== undefined && record.start < search.after) continue;
// The same overlap the check path ignores: the write was still running when this read began.
if (search.after !== undefined && overlapsWrite(search.after, record.start))
continue;
const view = search.viewOf(record.ordinal);
if (
!succeeded(record) ||