fix(environment): bound and validate write state history

This commit is contained in:
morluto authored and Cursor Agent committed 2026-10-21 22:29:39 +00:00
1 parent 19b50ea668
commit a057ea9784
8 files changed
+211 -9

No files matched your search

@@ -30,7 +30,10 @@ import {
prepareServedState,
} from "./environmentState.js";
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
import { EnvironmentStateStore } from "./environmentStateStore.js";
import {
EnvironmentStateStore,
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
} from "./environmentStateStore.js";
const fixture = environmentStateServeFixture();
const { source, state } = environmentStateServeInputs(fixture);
@@ -151,6 +154,53 @@ describe("loadEnvironmentState", () => {
});
});
describe("served collection history", () => {
it("refuses a new collection before retained collection and sequence history exceeds its budget", () => {
const store = new EnvironmentStateStore(state);
const original = state.entities[0];
if (original === undefined) throw new Error("Expected a seeded entity");
let refused: ReturnType<typeof store.insert> | undefined;
for (let index = 0; index < 2000; index += 1) {
const before = JSON.stringify(store.snapshot());
const result = store.insert(
{
...original,
collection: `/api/history/${String(index)}-${"x".repeat(900)}`,
id: `history-${String(index)}`,
},
0,
);
if (!result.ok) {
refused = result;
expect(JSON.stringify(store.snapshot())).toBe(before);
break;
}
const inserted = store.entity(
original.type,
`/api/history/${String(index)}-${"x".repeat(900)}`,
`history-${String(index)}`,
);
if (inserted === undefined) throw new Error("Expected inserted entity");
store.remove(inserted);
}
expect(refused?.ok).toBe(false);
if (refused?.ok !== false)
throw new Error("Expected a history budget refusal");
expect(refused.error).toMatchObject({
resource: "capacity",
retryable: false,
reportedLimits: {
collection_history_bytes: expect.any(Number),
max_collection_history_bytes:
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
},
remediationAction: expect.stringContaining("Reset the served state"),
});
expect(refused.error.reason).toContain("retained metadata budget");
expect(store.knownCollectionsOf(original.type).length).toBeLessThan(2000);
});
});
describe("deriveEnvironmentState", () => {
it("derives the state inspect-analysis-view projects from the capture with the manifest's rules, under the same Evidence id", () => {
const derived = deriveEnvironmentState(source, fixture.capture);
@@ -31,7 +31,8 @@ export const createdCollection = (
type: string,
): string | undefined => {
const segments = callSegments(tool, args);
if (segments === undefined) return undefined;
if (segments === undefined || segments.some(({ text }) => text.length === 0))
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;
@@ -19,6 +19,34 @@ import {
type StateContent,
} from "./environmentStateDocument.js";
/** Bound estimated collection and sequence-key metadata retained after served creates are deleted. */
export const ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES = 1024 * 1024;
const collectionHistoryCost = (
state: EnvironmentState,
type: string,
collection: string,
): number => {
const sequenceKeys = new Set<string>();
for (const plan of state.writes.values()) {
if (!plan.served || plan.target !== type) continue;
for (const rule of plan.rules) {
if (rule.rule === "sequence" && rule.scope === "collection")
sequenceKeys.add(`${plan.tool} ${rule.field} ${collection}`);
else if (rule.rule === "identity" && rule.generator === "sequence")
sequenceKeys.add(`${type} ${rule.field} ${collection}`);
}
}
return (
64 +
Buffer.byteLength(collection, "utf8") +
[...sequenceKeys].reduce(
(bytes, key) => bytes + 64 + Buffer.byteLength(key, "utf8"),
0,
)
);
};
/** What `data/get` reports: the current state as a `rea-state/v1` document. */
export interface StateSnapshot {
readonly rea_state: {
@@ -91,6 +119,8 @@ export class EnvironmentStateStore {
#started: readonly StateEntity[] = [];
/** Every collection spelling a type's entities have been held under since the last reset or load, kept when a collection empties. */
readonly #knownCollections = new Map<string, string[]>();
/** Estimated retained collection and sequence-key metadata, reset with the serving history. */
#collectionHistoryBytes = 0;
/** The collections of the Evidence's own state, which stay known after any load. */
#evidenceCollections: ReadonlyMap<string, readonly string[]> = new Map();
@@ -176,6 +206,7 @@ export class EnvironmentStateStore {
this.#started = this.#content.entities;
// A reset or load knows the Evidence's spellings and its own, never those of a state it replaced.
this.#knownCollections.clear();
this.#collectionHistoryBytes = 0;
this.#remember(this.#evidenceCollections);
this.#remember(this.#typeCollections);
}
@@ -184,7 +215,14 @@ export class EnvironmentStateStore {
for (const [type, collections] of named) {
const known = this.#knownCollections.get(type) ?? [];
for (const collection of collections)
if (!known.includes(collection)) known.push(collection);
if (!known.includes(collection)) {
known.push(collection);
this.#collectionHistoryBytes += collectionHistoryCost(
this.state,
type,
collection,
);
}
this.#knownCollections.set(type, known);
}
}
@@ -209,6 +247,30 @@ export class EnvironmentStateStore {
jsonBytes(entity) + (entities.length > 0 ? 1 : 0),
);
if (!bytes.ok) return bytes;
const known = this.#knownCollections.get(entity.type) ?? [];
if (!known.includes(entity.collection)) {
const historyBytes =
this.#collectionHistoryBytes +
collectionHistoryCost(this.state, entity.type, entity.collection);
if (historyBytes > ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES)
return err(
new AnalysisResourceConstraintError(
ENVIRONMENT_SERVE_OPERATION,
"capacity",
"Served collection and sequence history would exceed its retained metadata budget",
{
max_collection_history_bytes:
ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES,
collection_history_bytes: historyBytes,
},
{
retryable: false,
remediationAction:
"Reset the served state to clear collection history, then use fewer distinct collection paths.",
},
),
);
}
const members = this.inCollection(entity.type, entity.collection);
const anchor =
index < members.length
@@ -536,6 +536,25 @@ describe("collections a create chooses", () => {
?.collection,
).toBe("/api/orgs/acme1/books");
});
it("refuses an empty parent segment before storing an unreadable collection", () => {
const books = servedFrom(ROWS);
const tool = books.source.manifest.tools.find(
(candidate) => candidate.method === "POST",
)!;
const [org] = environmentToolArguments(tool).path.values();
const before = books.snapshot();
expect(
books.refusal(tool.name, { [org!]: "", body: { name: "Invalid" } }),
).toMatchObject({
code: "invalid_request",
message: expect.stringContaining("path argument to be non-empty"),
});
expect(books.snapshot()).toBe(before);
expect(
books.store.knownCollectionsOf("/api/orgs/{org}/books"),
).not.toContain("/api/orgs//books");
});
});
describe("missing entities after a delete", () => {
@@ -265,7 +265,12 @@ const answerCreate = (
);
const observed = observedEntity(serving, plan);
if (collection === undefined || observed === undefined)
return err(refused(tool, `Tool ${tool.name} needs every path argument.`));
return err(
refused(
tool,
`Tool ${tool.name} needs every path argument to be non-empty.`,
),
);
if (plan.collection !== null && plan.collection !== collection)
return err(
refused(
@@ -844,6 +844,32 @@ describe("what a check leaves pending", () => {
});
});
it("checks only a partial update's fields that remain after a later overwrite", () => {
const patched = described(1, "Patched", "survives");
const replaced = described(1, "Replaced", "survives");
const changedByOtherAction = described(1, "Archived", "survives");
const rows: TimedCaptureRow[] = [
["GET", "/api/books", summary([FIRST_BOOK, SECOND_BOOK])],
["GET", "/api/books/1", described(1, "First Book", "original")],
["GET", "/api/books/2", described(2, "Second Book", "other")],
[
"PATCH",
"/api/books/1",
patched,
200,
{ name: "Patched", description: "survives" },
],
["PUT", "/api/books/1", replaced, 200, { name: "Replaced" }],
["POST", "/api/books/1/archive", changedByOtherAction, 204, {}],
["GET", "/api/books/1", changedByOtherAction],
];
expect(gated(rows, "patch_books_by_id")).toMatchObject({
served: true,
checked_record_ordinals: [6],
contradicted_by: null,
});
});
it("neither resynchronises nor checks an entity by a read begun while a write to it still ran", () => {
const rows: TimedCaptureRow[] = [
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK]],
@@ -880,6 +906,38 @@ describe("what a check leaves pending", () => {
});
describe("what a later call must follow", () => {
it("rejects a later create whose unique field repeats a value already held", () => {
const existing = {
id: "123e4567-e89b-42d3-a456-426614174000",
ref: "223e4567-e89b-42d3-a456-426614174000",
name: "Existing",
};
const first = {
id: "323e4567-e89b-42d3-a456-426614174000",
ref: "423e4567-e89b-42d3-a456-426614174000",
name: "First",
};
const duplicate = {
id: "523e4567-e89b-42d3-a456-426614174000",
ref: first.ref,
name: "Second",
};
const rows: TimedCaptureRow[] = [
["GET", "/api/items", [existing]],
["GET", `/api/items/${existing.id}`, existing],
["POST", "/api/items", first, 201, { name: "First" }],
["GET", `/api/items/${first.id}`, first],
["GET", "/api/items", [existing, first]],
["POST", "/api/items", duplicate, 201, { name: "Second" }],
["GET", `/api/items/${duplicate.id}`, duplicate],
["GET", "/api/items", [existing, first, duplicate]],
];
expect(gated(rows, "post_items")).toMatchObject({
served: false,
reason: "unexplained-change",
});
});
it("refuses a create whose later call does not follow the sequence it learned", () => {
const ranked = (at: Book, rank: number) => ({ ...at, rank });
const third = ranked(book(3, "Third Book", bookClock(3)), 3);
@@ -220,8 +220,10 @@ export class WriteEffects {
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 {
if (left !== undefined) {
own!.set(tool, left);
if (tool !== at.writer) touched.get(at.entity)?.set(tool, left);
} else {
own!.delete(tool);
touched.get(at.entity)?.delete(tool);
}
@@ -199,10 +199,15 @@ const followsGenerated = (
return (
JSON.stringify(readClock(value)?.format) === JSON.stringify(rule.format)
);
if (rule.rule === "unique")
return (
isUuid(value) &&
!context.state
.ofType(created.type)
.some((held) => held.fields[rule.field] === value)
);
return (
(rule.rule !== "unique" &&
(rule.rule !== "identity" || rule.generator !== "uuid")) ||
isUuid(value)
rule.rule !== "identity" || rule.generator !== "uuid" || isUuid(value)
);
}) && followsSequences(context, draft, created);