fix(environment): check reads on the lines serving answers, and refuse plans later calls contradict

Greptile raised these on the list-lines branch against this branch's code.
- The gate chooses a read's line as serving does. A read with no query on a whole list falls back to a checked line that only pages it, and a line whose query repeats a name, which no call can name, is never checked.
- A read made while a modelled write to its targets was still running, starting before that write ended, checks nothing either way.
- A later call that stores a member otherwise than the plan (echoed by one call and stored as something else by another, or ruled differently) refuses the plan as unexplained, rather than keeping the first rule.
- A create keeps the spelling of a collection the capture listed empty: checked list lines of the type join the known spellings, so an encoded argument is not encoded twice.
- containsDelimited walks children one at a time, since spreading a very wide array into push overflowed the stack.
- The guide counts the confirmed write tools among the served catalog and the verifier's expected count.

Attribution bookkeeping moves to environmentStateWriteAttribution.ts and the create's collection choice to environmentStateCollections.ts, keeping files under the line limit. The verdicts over the 50 captures are unchanged plan by plan, and the smoke applies all 31 served write tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-21 08:22:31 -07:00
1 parent 73c0e9b50a
commit 9f513d07e5
14 files changed
+515 -258

No files matched your search

+9 -8
View File
@@ -5,7 +5,8 @@ environment: an MCP server whose tools answer from what one retained web
network capture observed, plus the environment card the agentenv framework
reads to discover it. By default the server derives the
[state the capture observed](#the-state-a-capture-observed) at startup and
answers read tools from it; `--replay-only` replays the capture's records
answers read tools from it, and the write tools whose effect the capture
confirmed by changing it; `--replay-only` replays the capture's records
instead. Nothing is fetched from the recorded application; every answer comes
from retained Evidence. `rea environment-export` writes the same pair as a
bundle folder `agent-env run` builds and runs; see
@@ -271,11 +272,11 @@ document, with the same signal exit status.
Three routes are served:
| Route | Answer |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /mcp` | The MCP server: from the state, one tool per read tool, answering from the state where it backs them; in replay, one tool per manifest tool, replaying records |
| `GET /.well-known/agent-env.json` | The environment card |
| `POST /agentenv` | From the state, `data/reset`, `data/add` and `data/get`; in replay, JSON-RPC `-32601 method not found: <method>` for every method |
| Route | Answer |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /mcp` | The MCP server: from the state, one tool per read tool, answering from the state where it backs them, and one per write tool whose effect the capture confirmed, applying it to the state; in replay, one tool per manifest tool, replaying records |
| `GET /.well-known/agent-env.json` | The environment card |
| `POST /agentenv` | From the state, `data/reset`, `data/add` and `data/get`; in replay, JSON-RPC `-32601 method not found: <method>` for every method |
Numeric JSON-RPC IDs outside JavaScript's safe range or whose precision would
be lost are refused as invalid requests with a null ID. They are never echoed
@@ -667,8 +668,8 @@ name from Python sources the image does not have.
`verify.py` is the framework's `async def verify(mcp_url)` shape over its
Python MCP client: it lists the tools and checks the count equals the number
the bundle serves (the manifest's read tools from the state, every manifest
tool in replay), calls one tool with no arguments and checks the call did not
the bundle serves (from the state, the manifest's read tools and the write
tools whose effect the capture confirmed; every manifest tool in replay), calls one tool with no arguments and checks the call did not
fail and its `status` is the expected one, and returns one `serve` result
whose description reports both checks. The tool is the first served `GET` tool
without path parameters whose answer to no arguments is not an error, and the
@@ -112,7 +112,9 @@ explained as one of:
Any other change is `unexplained-change`. An increment or a slug applies to
every call that could take it, so the plan is also `unexplained-change` when
such a rule would give a call of the tool another value than the one it
recorded.
recorded, or when a later call treats a member otherwise than the plan: one
call echoes it and another stores something else, or the two rule it
differently.
**Deletes.** A delete removes the entity. Its response is the exemplar's
response, with the entity re-rendered when the response carries it.
@@ -177,7 +179,13 @@ from the state:
chosen as replay chooses the line for the read's call: each path parameter
the read's value verbatim or once percent-decoded, preferring verbatim
spellings, then recorded order, and the query values decoded, in any order.
A list is compared as served from the collection of that line.
A read with no query on a whole list falls back, as serving does, on a
checked line that only pages it. A line whose query repeats a name is never
chosen, since no call's arguments can name it. A list is compared as served
from the collection of that line.
- A read made while a modelled write to its entity or collection was still
running, starting before that write ended, checks nothing: it may show the
state before or after the write.
**Comparison.** The read's answer is the full response serving would give from
the copy: an item read's entity rendered into its item view's base recording,
@@ -269,9 +277,10 @@ recording instead.
A create in a collection held under more than one spelling (`acme1` and
`%61cme1`) goes to the one the call spells verbatim, as item calls choose their
entity; a collection keeps its spelling after its last entity is deleted. A
reset or load knows the spellings of the Evidence's state and of the state it
installs, never those of a state it replaced; when two spellings fit a call
equally, the one holding entities wins. `data/add` refuses a document that `data/get` would write back past the
reset or load knows the spellings of the Evidence's state, of the state it
installs and of the type's checked list lines (so a collection the capture
listed empty keeps its spelling), never those of a state it replaced. When two
spellings fit a call equally, the one holding entities wins. `data/add` refuses a document that `data/get` would write back past the
32 MiB intake limit once it adds the members a document may omit, such as
`types` and the state Evidence id.
@@ -258,3 +258,19 @@ A ninth round made a create prefer a collection the state holds over an equally
fitting spelling known only from the Evidence, so a create after a load lands in
the loaded collection and reads back. Its test fails on the previous head; the
verdicts and the smoke are unchanged and the gates passed again.
A tenth round settled six findings Greptile raised on the list-lines branch
against this branch's code:
- a read with no query on a whole list is checked on the paging line serving
answers it from, and a line whose query repeats a name is never checked;
- a read made while a write to its targets was still running checks nothing;
- a later call that stores a member otherwise than the plan refuses the plan;
- a create keeps the spelling of a collection the capture listed empty;
- a wide array no longer overflows the stack while checking path-dependent
values;
- the guide counts served write tools among the catalog's.
Each new test fails on the previous head. The verdicts over the 50 captures
are unchanged plan by plan, the smoke applies all 31 served write tools, and
the gates passed again.
@@ -0,0 +1,49 @@
import { collectionPathOf } from "../../domain/analysisView/environmentStateRules.js";
import {
callSegments,
collectionMatch,
type StateServing,
} from "./environmentStateAnswer.js";
import type { ReplayArguments } from "./environmentReplay.js";
import type { EnvironmentManifestTool } from "./environmentSource.js";
/** The collections a create may go to: those the store knows, held ones first, then those the type's checked list lines read, so an empty one keeps its spelling. */
const creatableCollectionsOf = (
serving: StateServing,
type: string,
): readonly string[] => {
const listed: string[] = [];
for (const view of serving.store.state.views.values())
if (view.shape === "list" && view.type === type)
for (const ordinal of view.checked_record_ordinals) {
const exchange = serving.source.exchanges.get(ordinal);
if (exchange !== undefined)
listed.push(collectionPathOf(exchange, false));
}
return [...new Set([...serving.store.knownCollectionsOf(type), ...listed])];
};
/** The collection a create writes to: a known collection the call's path matches, else the path as supplied. */
export const createdCollection = (
serving: StateServing,
tool: EnvironmentManifestTool,
args: ReplayArguments,
type: string,
): string | undefined => {
const segments = callSegments(tool, args);
if (segments === undefined) return undefined;
// The spelling the call supplies verbatim wins, as item calls choose their entity, then the first known: held ones lead.
let held: string | undefined;
let verbatim = -1;
for (const collection of creatableCollectionsOf(serving, type)) {
const matched = collectionMatch(collection, segments);
if (matched !== undefined && matched > verbatim) {
held = collection;
verbatim = matched;
}
}
return (
held ??
`/${segments.map(({ text, supplied }) => (supplied ? encodeURIComponent(text) : text)).join("/")}`
);
};
@@ -591,6 +591,37 @@ describe("values the state started with", () => {
expect(create("acme1", "Epsilon")).toBe("/api/orgs/a%63me1/books");
});
it("creates in the spelling of a collection the capture listed empty", () => {
const beta = orgBook(2, "acme2", "Beta");
const gamma = orgBook(3, "my org1", "Gamma");
const delta = orgBook(4, "acme2", "Delta");
// The state starts before the first create, when the encoded organisation's list was empty.
const books = servedFrom([
["GET", "/api/orgs/my%20org1/books", []],
["GET", "/api/orgs/acme2/books", [beta]],
["GET", "/api/orgs/acme2/books/2", beta],
["POST", "/api/orgs/my%20org1/books", gamma, 201, { name: "Gamma" }],
["GET", "/api/orgs/my%20org1/books", [gamma]],
["POST", "/api/orgs/acme2/books", delta, 201, { name: "Delta" }],
["GET", "/api/orgs/acme2/books", [beta, delta]],
]);
const tool = books.source.manifest.tools.find(
(candidate) => candidate.method === "POST",
)!;
const [parent] = environmentToolArguments(tool).path.values();
books.answer(tool.name, {
[parent!]: "my%20org1",
body: { name: "Epsilon" },
});
expect(
books.store
.snapshot()
.rea_state.entities.find(
(entity) => entity.fields["name"] === "Epsilon",
)?.collection,
).toBe("/api/orgs/my%20org1/books");
});
it("forgets the spellings of a loaded state once reset", () => {
const { books, create } = twoOrgs("%61cme1", "acme1");
const { rea_state: held } = books.store.snapshot();
@@ -51,6 +51,7 @@ import {
type StateProvenance,
type StateServing,
} from "./environmentStateAnswer.js";
import { createdCollection } from "./environmentStateCollections.js";
import {
isLoadableValue,
unloadableMember,
@@ -239,31 +240,6 @@ const writeAnswer = (
return stateAnswer(serving, { exchange, exact }, body.value ?? null, state);
};
/** The collection a create writes to: a held collection the call's path matches, else the path as supplied. */
const createdCollection = (
serving: StateServing,
request: WriteRequest,
): string | undefined => {
const segments = callSegments(request.tool, request.args);
if (segments === undefined) return undefined;
// The spelling the call supplies verbatim wins, as item calls choose their entity, then the first known: held ones lead.
let held: string | undefined;
let verbatim = -1;
for (const collection of serving.store.knownCollectionsOf(
request.plan.target ?? "",
)) {
const matched = collectionMatch(collection, segments);
if (matched !== undefined && matched > verbatim) {
held = collection;
verbatim = matched;
}
}
return (
held ??
`/${segments.map(({ text, supplied }) => (supplied ? encodeURIComponent(text) : text)).join("/")}`
);
};
const answerCreate = (
serving: StateServing,
request: WriteRequest,
@@ -272,7 +248,12 @@ const answerCreate = (
const { tool, plan } = request;
const type = plan.target ?? "";
const idField = serving.store.type(type)?.id_field ?? "";
const collection = createdCollection(serving, request);
const collection = createdCollection(
serving,
request.tool,
request.args,
plan.target ?? "",
);
const observed = observedEntity(serving, plan);
if (collection === undefined || observed === undefined)
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
@@ -527,3 +527,89 @@ describe("updates whose effect later writes erase", () => {
});
});
});
describe("reads as serving answers them", () => {
it("checks a read with no query on the line serving answers it from, a whole list's paging line", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books?limit=50", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
];
expect(gated(rows, "post_books")).toMatchObject({
served: true,
checked_record_ordinals: [4],
});
});
it("checks nothing by a line whose query repeats a name, which no call can name", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books?tag=a&tag=b", [FIRST_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["PATCH", "/api/books/1", RENAMED, 200, { name: "Renamed Book" }],
["GET", "/api/books?tag=a&tag=b", [RENAMED]],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
checked_record_ordinals: [],
});
});
it("checks nothing by a read made while a write to its targets was still running", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
// The create runs from second 3 to 4.5; the list at second 4 still shows the books before it.
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }, 1500],
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, THIRD_BOOK]],
];
expect(gated(rows, "post_books")).toMatchObject({
served: true,
checked_record_ordinals: [5],
contradicted_by: null,
});
});
it("refuses an update a later call stores otherwise than the plan echoed", () => {
const formatted = (at: number, format: string): Book => ({
...renamedBook(FIRST_BOOK, "First Book", at),
format,
});
const rows: TimedCaptureRow[] = [
[
"GET",
"/api/books",
[
{ ...FIRST_BOOK, format: "EBOOK" },
{ ...SECOND_BOOK, format: "EBOOK" },
],
],
["GET", "/api/books/1", { ...FIRST_BOOK, format: "EBOOK" }],
["GET", "/api/books/2", { ...SECOND_BOOK, format: "EBOOK" }],
[
"PATCH",
"/api/books/1",
formatted(3, "PAPER"),
200,
{ format: "PAPER" },
],
["GET", "/api/books/1", formatted(3, "PAPER")],
[
"PATCH",
"/api/books/1",
formatted(5, "HARDCOVER"),
200,
{ format: "hardcover" },
],
["GET", "/api/books/1", formatted(5, "HARDCOVER")],
];
// One call echoed the format, the other stored another value: no one plan serves both.
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: false,
reason: "unexplained-change",
});
});
});
@@ -27,12 +27,16 @@ import {
withBodies,
writtenFieldChecks,
SimulatedState,
WriteEffects,
type CheckOutcome,
type CheckedLines,
type TouchedTargets,
type ServedLine,
} from "./environmentStateWriteChecks.js";
import {
touchTarget,
WriteEffects,
type PendingTargets,
type TouchedTargets,
} from "./environmentStateWriteAttribution.js";
import { applyCreate } from "./environmentStateWriteCreate.js";
import {
applyDelete,
@@ -91,8 +95,8 @@ export class EnvironmentStateWriteSimulation {
readonly #touched: TouchedTargets = new Map();
readonly #effects = new WriteEffects();
readonly #lastCheck = new Map<string, number>();
/** The earliest modelled write to each target since its last check. */
readonly #touchedSince = new Map<string, number>();
/** The earliest modelled write to each target since its last check, and when the latest of them ended. */
readonly #touchedSince: PendingTargets = new Map();
/** Timeline indexes of writes the gate does not model. */
readonly #unmodelledAt: number[] = [];
readonly #log: {
@@ -207,6 +211,7 @@ export class EnvironmentStateWriteSimulation {
index,
tool: tool.name,
path: record.path,
end: record.end,
});
}
@@ -217,6 +222,7 @@ export class EnvironmentStateWriteSimulation {
readonly index: number;
readonly tool: string;
readonly path: string;
readonly end: number;
},
): void {
if (result.kind === "unknown") {
@@ -239,17 +245,10 @@ export class EnvironmentStateWriteSimulation {
if (target === result.targets[0]) this.#touched.delete(target);
continue;
}
if (!this.#touchedSince.has(target))
this.#touchedSince.set(target, write.index);
const tools = this.#touched.get(target) ?? new Map();
const before = tools.get(write.tool);
tools.set(
write.tool,
result.fields === undefined || before === null
? null
: [...new Set([...(before ?? []), ...result.fields])],
);
this.#touched.set(target, tools);
touchTarget(this.#touched, this.#touchedSince, target, {
...write,
fields: result.fields,
});
}
if (result.kind !== "applied") return;
this.#log.push(write);
@@ -357,7 +356,10 @@ export class EnvironmentStateWriteSimulation {
const outcome = this.#check(view, record);
if (outcome === undefined) return;
const keys = [...outcome.targets, ...outcome.unheld];
if (keys.some((key) => this.#taint.tainted(key))) return;
// A read made while a write to its targets was still running may show either state, so it checks nothing.
const running = (key: string) =>
(this.#touchedSince.get(key)?.until ?? -Infinity) > record.start;
if (keys.some((key) => this.#taint.tainted(key) || running(key))) return;
const exercised = new Set<string>();
for (const key of keys)
for (const name of this.#touched.get(key)?.keys() ?? [])
@@ -392,7 +394,7 @@ export class EnvironmentStateWriteSimulation {
/** Whether a write the gate does not model came after the earliest modelled write a failed check exercised. */
#interfered(keys: readonly string[]): boolean {
const since = Math.min(
...keys.map((key) => this.#touchedSince.get(key) ?? Infinity),
...keys.map((key) => this.#touchedSince.get(key)?.since ?? Infinity),
);
return this.#unmodelledAt.some((index) => index > since);
}
@@ -0,0 +1,175 @@
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
export type TouchedTargets = Map<string, Map<string, readonly string[] | null>>;
/** Per target, the earliest modelled write since its last check and when the latest of them ended. */
export type PendingTargets = Map<string, { since: number; until: number }>;
/** Record a modelled write on a target: the fields it wrote (undefined: all), when it began and when it ended. */
export const touchTarget = (
touched: TouchedTargets,
pending: PendingTargets,
target: string,
write: {
readonly index: number;
readonly tool: string;
readonly end: number;
readonly fields: readonly string[] | undefined;
},
): void => {
const since = pending.get(target);
pending.set(target, {
since: since?.since ?? write.index,
until: Math.max(since?.until ?? -Infinity, write.end),
});
const tools = touched.get(target) ?? new Map();
const before = tools.get(write.tool);
tools.set(
write.tool,
write.fields === undefined || before === null
? null
: [...new Set([...(before ?? []), ...write.fields])],
);
touched.set(target, tools);
};
/** The fields of a write's effect on an entity no later write has overwritten: all of them for a create or delete (null). */
type ChangedFields = readonly string[] | null;
/** What a later write overwrites: the whole entity, the fields it sets, or every field (a recorded outcome). */
interface Overwrite {
readonly whole: boolean;
readonly fields: ReadonlySet<string> | "all";
}
/** What an earlier effect keeps after a write: nothing once every field it holds is overwritten; an update leaves a create's or delete's whole effect. */
const remaining = (
claim: ChangedFields,
write: Overwrite,
): ChangedFields | undefined => {
if (write.whole) return undefined;
if (claim === null) return null;
const { fields } = write;
if (fields === "all") return undefined;
const left = claim.filter((field) => !fields.has(field));
return left.length > 0 ? left : undefined;
};
/** Add a write's changed fields under a key: all of them once any write there changed the whole entity. */
const record = (
map: Map<string, ChangedFields>,
key: string,
whole: boolean,
changed: readonly string[],
): Map<string, ChangedFields> => {
const before = map.get(key);
return map.set(
key,
whole || before === null
? null
: [...new Set([...(before ?? []), ...changed])],
);
};
/**
* What each write changed since the targets it touched were last checked, less what later writes overwrote, so a
* write whose effect is gone drops out: from the entity's target, and from its collection's once no entity it changed
* there keeps any of it.
*/
export class WriteEffects {
/** Per entity target, the fields each tool changed there. */
readonly #changed = new Map<string, Map<string, ChangedFields>>();
/** Per collection target, the entities each tool's claim there rests on, with the fields it changed on each. */
readonly #members = new Map<
string,
Map<string, Map<string, ChangedFields>>
>();
/** Forget a checked target's writes, as the touched targets do. */
checked(key: string): void {
this.#changed.delete(key);
this.#members.delete(key);
}
/** A write's effect replaces what earlier writes left of theirs, then joins them. */
supersede(
touched: TouchedTargets,
writer: string,
effect: {
readonly targets: readonly string[];
readonly fields?: readonly string[];
readonly changed?: {
readonly fields: readonly string[];
readonly accumulated: readonly string[];
};
},
): void {
const [entity, collection] = effect.targets as [string, string?];
const changed = effect.changed?.fields ?? effect.fields ?? [];
const whole = effect.fields === undefined;
this.#overwrite(
touched,
{ entity, collection, writer },
{
whole,
fields: new Set(
changed.filter(
(field) => !effect.changed?.accumulated.includes(field),
),
),
},
);
this.#changed.set(
entity,
record(this.#changed.get(entity) ?? new Map(), writer, whole, changed),
);
if (collection === undefined) return;
const byTool = this.#members.get(collection) ?? new Map();
byTool.set(
writer,
record(byTool.get(writer) ?? new Map(), entity, whole, changed),
);
this.#members.set(collection, byTool);
}
/** A recorded outcome is the entity's new baseline: what earlier writes changed on it is gone, its membership is not. */
observed(touched: TouchedTargets, targets: readonly string[]): void {
const [entity, collection] = targets as [string, string?];
this.#overwrite(
touched,
{ entity, collection, writer: undefined },
{ whole: false, fields: "all" },
);
}
#overwrite(
touched: TouchedTargets,
at: {
readonly entity: string;
readonly collection: string | undefined;
readonly writer: string | undefined;
},
write: Overwrite,
): void {
const own = this.#changed.get(at.entity);
for (const [tool, claim] of own ?? []) {
const left = tool === at.writer ? claim : remaining(claim, write);
if (left !== undefined) own!.set(tool, left);
else {
own!.delete(tool);
touched.get(at.entity)?.delete(tool);
}
}
if (at.collection === undefined) return;
const byTool = this.#members.get(at.collection);
for (const [tool, entities] of byTool ?? []) {
const claim = entities.get(at.entity);
if (tool === at.writer || claim === undefined) continue;
const left = remaining(claim, write);
if (left !== undefined) entities.set(at.entity, left);
else entities.delete(at.entity);
if (entities.size > 0) continue;
byTool!.delete(tool);
touched.get(at.collection)?.delete(tool);
}
}
}
@@ -13,6 +13,7 @@ import {
lastSegmentValue,
listItems,
missingRequiredFields,
pagesOnly,
renderStateEntity,
renderStateList,
replaceAt,
@@ -29,24 +30,25 @@ import {
type StateView,
} from "./environmentStateRules.js";
/** A query as replay compares a call's: decoded values by name, in any order. A query repeating a name, which no call's arguments can carry, keeps its spelling. */
const queryKey = (query: string | undefined): string => {
/** A query as replay compares a call's: decoded values by name, in any order; undefined when it repeats a name, which no call's arguments can carry. */
const queryKey = (query: string | undefined): string | undefined => {
const pairs = [...iterateUrlQueryParameters(query ?? "")];
return new Set(pairs.map(([name]) => name)).size === pairs.length
? compositeKey(pairs.sort(([left], [right]) => compare(left, right)))
: compositeKey([query ?? ""]);
: undefined;
};
interface CheckedLine {
readonly ordinal: number;
readonly segments: readonly string[];
}
/** A list or document view's checked lines, by query, in the tool's recorded order, and which path segments are the tool's parameters. */
export interface CheckedLines {
readonly params: ReadonlySet<number>;
readonly byQuery: ReadonlyMap<
string,
readonly {
readonly ordinal: number;
readonly segments: readonly string[];
}[]
>;
readonly byQuery: ReadonlyMap<string, readonly CheckedLine[]>;
/** A whole list's lines that only page it, which serving answers a call supplying no query from. */
readonly unfiltered: readonly CheckedLine[];
}
/** The line a read's call is answered from, and the collection serving lists for it. */
@@ -70,24 +72,23 @@ export const checkedLines = (input: {
const tool = tools.get(view.tool);
if (view.shape === "recorded" || view.shape === "item" || !tool) continue;
const checked = new Set(view.checked_record_ordinals);
const byQuery = new Map<
string,
{ readonly ordinal: number; readonly segments: readonly string[] }[]
>();
const byQuery = new Map<string, CheckedLine[]>();
const unfiltered: CheckedLine[] = [];
const whole = view.shape === "list" && view.membership === "all";
for (const ordinal of tool.record_ordinals) {
const record = byOrdinal.get(ordinal);
if (!checked.has(ordinal) || record === undefined) continue;
const key = queryKey(record.query);
const lines = byQuery.get(key) ?? [];
lines.push({ ordinal, segments: segmentsOf(record.path) });
byQuery.set(key, lines);
const key = record === undefined ? undefined : queryKey(record.query);
if (!checked.has(ordinal) || key === undefined) continue;
const line = { ordinal, segments: segmentsOf(record!.path) };
byQuery.set(key, [...(byQuery.get(key) ?? []), line]);
if (whole && pagesOnly(record!.query)) unfiltered.push(line);
}
const params = new Set(
segmentsOf(tool.path_template).flatMap((segment, index) =>
isParam(segment) ? [index] : [],
),
);
byView.set(view.tool, { params, byQuery });
byView.set(view.tool, { params, byQuery, unfiltered });
}
return byView;
};
@@ -95,27 +96,39 @@ export const checkedLines = (input: {
/**
* The checked line serving answers a read's call from, ranked as replay ranks lines: every
* parameter the read's value verbatim or once percent-decoded and the query the read's, then the
* most verbatim parameters, then recorded order.
* most verbatim parameters, then recorded order. A read with no query falls back, as serving does,
* on a whole list's lines that only page it.
*/
export const servedLine = (
lines: CheckedLines | undefined,
record: StateRecordFacts,
): ServedLine | undefined => {
const key = queryKey(record.query);
if (lines === undefined || key === undefined) return undefined;
return (
bestLine(lines, lines.byQuery.get(key) ?? [], record) ??
([...iterateUrlQueryParameters(record.query ?? "")].length === 0
? bestLine(lines, lines.unfiltered, record)
: undefined)
);
};
const bestLine = (
lines: CheckedLines,
candidates: readonly CheckedLine[],
record: StateRecordFacts,
): ServedLine | undefined => {
const read = segmentsOf(record.path);
let best: { line: ServedLine; verbatim: number } | undefined;
for (const { ordinal, segments } of lines?.byQuery.get(
queryKey(record.query),
) ?? []) {
for (const { ordinal, segments } of candidates) {
if (segments.length !== read.length) continue;
let verbatim = 0;
const matches = segments.every((segment, index) => {
if (segment === read[index]) {
if (lines!.params.has(index)) verbatim += 1;
if (lines.params.has(index)) verbatim += 1;
return true;
}
return (
lines!.params.has(index) && decodedSegment(segment) === read[index]
);
return lines.params.has(index) && decodedSegment(segment) === read[index];
});
if (matches && (best === undefined || verbatim > best.verbatim))
best = {
@@ -126,151 +139,6 @@ export const servedLine = (
return best?.line;
};
/** Modelled writes per target since its last check, with the fields each wrote (null: all). */
export type TouchedTargets = Map<string, Map<string, readonly string[] | null>>;
/** The fields of a write's effect on an entity no later write has overwritten: all of them for a create or delete (null). */
type ChangedFields = readonly string[] | null;
/** What a later write overwrites: the whole entity, the fields it sets, or every field (a recorded outcome). */
interface Overwrite {
readonly whole: boolean;
readonly fields: ReadonlySet<string> | "all";
}
/** What an earlier effect keeps after a write: nothing once every field it holds is overwritten; an update leaves a create's or delete's whole effect. */
const remaining = (
claim: ChangedFields,
write: Overwrite,
): ChangedFields | undefined => {
if (write.whole) return undefined;
if (claim === null) return null;
const { fields } = write;
if (fields === "all") return undefined;
const left = claim.filter((field) => !fields.has(field));
return left.length > 0 ? left : undefined;
};
/** Add a write's changed fields under a key: all of them once any write there changed the whole entity. */
const record = (
map: Map<string, ChangedFields>,
key: string,
whole: boolean,
changed: readonly string[],
): Map<string, ChangedFields> => {
const before = map.get(key);
return map.set(
key,
whole || before === null
? null
: [...new Set([...(before ?? []), ...changed])],
);
};
/**
* What each write changed since the targets it touched were last checked, less what later writes overwrote, so a
* write whose effect is gone drops out: from the entity's target, and from its collection's once no entity it changed
* there keeps any of it.
*/
export class WriteEffects {
/** Per entity target, the fields each tool changed there. */
readonly #changed = new Map<string, Map<string, ChangedFields>>();
/** Per collection target, the entities each tool's claim there rests on, with the fields it changed on each. */
readonly #members = new Map<
string,
Map<string, Map<string, ChangedFields>>
>();
/** Forget a checked target's writes, as the touched targets do. */
checked(key: string): void {
this.#changed.delete(key);
this.#members.delete(key);
}
/** A write's effect replaces what earlier writes left of theirs, then joins them. */
supersede(
touched: TouchedTargets,
writer: string,
effect: {
readonly targets: readonly string[];
readonly fields?: readonly string[];
readonly changed?: {
readonly fields: readonly string[];
readonly accumulated: readonly string[];
};
},
): void {
const [entity, collection] = effect.targets as [string, string?];
const changed = effect.changed?.fields ?? effect.fields ?? [];
const whole = effect.fields === undefined;
this.#overwrite(
touched,
{ entity, collection, writer },
{
whole,
fields: new Set(
changed.filter(
(field) => !effect.changed?.accumulated.includes(field),
),
),
},
);
this.#changed.set(
entity,
record(this.#changed.get(entity) ?? new Map(), writer, whole, changed),
);
if (collection === undefined) return;
const byTool = this.#members.get(collection) ?? new Map();
byTool.set(
writer,
record(byTool.get(writer) ?? new Map(), entity, whole, changed),
);
this.#members.set(collection, byTool);
}
/** A recorded outcome is the entity's new baseline: what earlier writes changed on it is gone, its membership is not. */
observed(touched: TouchedTargets, targets: readonly string[]): void {
const [entity, collection] = targets as [string, string?];
this.#overwrite(
touched,
{ entity, collection, writer: undefined },
{ whole: false, fields: "all" },
);
}
#overwrite(
touched: TouchedTargets,
at: {
readonly entity: string;
readonly collection: string | undefined;
readonly writer: string | undefined;
},
write: Overwrite,
): void {
const own = this.#changed.get(at.entity);
for (const [tool, claim] of own ?? []) {
const left = tool === at.writer ? claim : remaining(claim, write);
if (left !== undefined) own!.set(tool, left);
else {
own!.delete(tool);
touched.get(at.entity)?.delete(tool);
}
}
if (at.collection === undefined) return;
const byTool = this.#members.get(at.collection);
for (const [tool, entities] of byTool ?? []) {
const claim = entities.get(at.entity);
if (tool === at.writer || claim === undefined) continue;
const left = remaining(claim, write);
if (left !== undefined) entities.set(at.entity, left);
else entities.delete(at.entity);
if (entities.size > 0) continue;
byTool!.delete(tool);
touched.get(at.collection)?.delete(tool);
}
}
}
export const entityTarget = (type: string, collection: string, id: string) =>
`e${compositeKey([type, collection, id])}`;
export const collectionTarget = (type: string, collection: string) =>
@@ -251,6 +251,26 @@ const refineUpdate = (
];
};
/** Whether a later call treats a member or field otherwise than the plan: echoed in one and stored as something else in the other, or ruled differently. */
const conflictsWithPlan = (
plan: WriteDraft["plan"],
explained: ReturnType<typeof explainUpdate>,
): boolean => {
const body = plan.body!;
const fixed = new Set(body.fixed.map((entry) => entry.member));
const rules = new Map(plan.rules.map((rule) => [rule.field, rule]));
return (
explained.body.accepted.some((member) => fixed.has(member)) ||
explained.body.fixed.some((entry) =>
body.accepted.includes(entry.member),
) ||
explained.rules.some((rule) => {
const held = rules.get(rule.field);
return held !== undefined && !same(held, rule);
})
);
};
/** 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";
@@ -376,7 +396,8 @@ export const applyUpdate = (
if (
built(draft) &&
!unknown &&
contradictsEarlierCalls(draft, explained.rules)
(contradictsEarlierCalls(draft, explained.rules) ||
conflictsWithPlan(draft.plan, explained))
) {
refuse(draft, step, "unexplained-change");
return recorded();
@@ -187,6 +187,20 @@ it("finds a delimited token in any string nested in objects and arrays", () => {
expect(containsDelimited(["2026", true, [{ e: "x2" }]], "2")).toBe(false);
});
it("searches arrays and objects too wide to spread into one call", () => {
const wide = Array.from({ length: 500_000 }, () => 0);
expect(containsDelimited(wide, "2")).toBe(false);
expect(containsDelimited({ wide: [...wide, "see /issues/2"] }, "2")).toBe(
true,
);
expect(
containsDelimited(
Object.fromEntries(wide.map((value, index) => [String(index), value])),
"2",
),
).toBe(false);
});
it("derives a deterministic version 4 UUID per seed", () => {
const first = seededUuid("seed-a", UUID_EXEMPLAR);
expect(first).toMatch(V4_UUID);
@@ -159,9 +159,10 @@ export const containsDelimited = (value: JsonValue, token: string): boolean => {
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === "string") {
if (delimitedOffsets(next, token).length > 0) return true;
} else if (Array.isArray(next)) pending.push(...next);
else if (typeof next === "object" && next !== null)
pending.push(...Object.values(next));
} else if (typeof next === "object" && next !== null)
// One at a time: spreading a wide array into push overflows the call stack.
for (const child of Array.isArray(next) ? next : Object.values(next))
pending.push(child);
}
return false;
};
+28 -25
View File
@@ -314,13 +314,14 @@ export const environmentManifestCaptureFixture = (
export const environmentStateCaptureFixture = () =>
environmentManifestCaptureFixture(environmentStateHar().log.entries);
/** One synthetic exchange: method, path, JSON response body (a string is recorded as written), status and JSON request body. */
/** One synthetic exchange: method, path, JSON response body (a string is recorded as written), status, JSON request body and duration in milliseconds. */
export type TimedCaptureRow = readonly [
method: string,
path: string,
body?: JsonValue,
status?: number,
request?: JsonValue,
time?: number,
];
/** Capture Evidence of HAR entries one second apart on one origin, as the environment-state view orders them. */
@@ -329,30 +330,32 @@ export const timedCaptureEvidence = (
): Evidence =>
analysisViewCaptureEvidence(
environmentManifestCaptureFixture(
rows.map(([method, path, body = null, status = 200, sent], index) => ({
startedDateTime: new Date(
Date.parse("2026-10-17T18:00:00.000Z") + index * 1000,
).toISOString(),
time: 5,
request: {
method,
url: `http://app.example${path}`,
...(sent === undefined
? {}
: {
postData: {
mimeType: "application/json",
text: JSON.stringify(sent),
},
}),
},
response: {
status,
content: {
mimeType: "application/json",
text: typeof body === "string" ? body : JSON.stringify(body),
rows.map(
([method, path, body = null, status = 200, sent, time = 5], index) => ({
startedDateTime: new Date(
Date.parse("2026-10-17T18:00:00.000Z") + index * 1000,
).toISOString(),
time,
request: {
method,
url: `http://app.example${path}`,
...(sent === undefined
? {}
: {
postData: {
mimeType: "application/json",
text: JSON.stringify(sent),
},
}),
},
},
})),
response: {
status,
content: {
mimeType: "application/json",
text: typeof body === "string" ? body : JSON.stringify(body),
},
},
}),
),
),
);