From a057ea978497a87c67ab22eb4d35971c780062ca Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:09:36 +0800 Subject: [PATCH] fix(environment): bound and validate write state history --- .../environment/environmentState.test.ts | 52 ++++++++++++++- .../environmentStateCollections.ts | 3 +- .../environment/environmentStateStore.ts | 64 ++++++++++++++++++- .../environmentStateWriteAnswer.test.ts | 19 ++++++ .../environmentStateWriteAnswer.ts | 7 +- .../EnvironmentStateWriteSimulation.test.ts | 58 +++++++++++++++++ .../environmentStateWriteAttribution.ts | 6 +- .../environmentStateWriteCreate.ts | 11 +++- 8 files changed, 211 insertions(+), 9 deletions(-) diff --git a/src/application/environment/environmentState.test.ts b/src/application/environment/environmentState.test.ts index e9e866c158..cf52caf115 100644 --- a/src/application/environment/environmentState.test.ts +++ b/src/application/environment/environmentState.test.ts @@ -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 | 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); diff --git a/src/application/environment/environmentStateCollections.ts b/src/application/environment/environmentStateCollections.ts index 86fa2d5676..6f535eaf27 100644 --- a/src/application/environment/environmentStateCollections.ts +++ b/src/application/environment/environmentStateCollections.ts @@ -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; diff --git a/src/application/environment/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts index 51fe95b78c..dc712b4c02 100644 --- a/src/application/environment/environmentStateStore.ts +++ b/src/application/environment/environmentStateStore.ts @@ -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(); + 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(); + /** 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 = 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 diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index 15df125e43..c4caa15de9 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -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", () => { diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index ccd24cdee0..00dda491f7 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -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( diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts index 137daade4a..c48989893b 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts @@ -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); diff --git a/src/domain/analysisView/environmentStateWriteAttribution.ts b/src/domain/analysisView/environmentStateWriteAttribution.ts index ddd3f5b540..49b551f1be 100644 --- a/src/domain/analysisView/environmentStateWriteAttribution.ts +++ b/src/domain/analysisView/environmentStateWriteAttribution.ts @@ -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); } diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 6bc14d6fc1..05e516d8f5 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -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);