From 82556cd550f570c87d162876ef293ba4c121e92f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 12:56:51 -0700 Subject: [PATCH] fix(environment): answer unfiltered lists from their own parent; describe served writes An unfiltered list call chose the first paging-only line of the tool whatever its path arguments named, so with /api/orgs/1/books?limit=50 and /api/orgs/2/books?limit=50 behind one tool, a call for org 2 got org 1's books. The line must now be of the collection the call's path arguments name (matched as item calls match, chosen as replay chooses); a parent with no such line answers from its recording. The serving guide and the evidence-workflow reference still said state mode leaves write tools out and that no write has an effect; they now describe confirmed write tools being served and what a served write does not model. Co-Authored-By: Claude Opus 5.5 --- .../references/evidence-workflows.md | 16 +++++--- docs/agent-environments.md | 38 +++++++++++-------- .../environment/environmentBundle.ts | 2 +- .../environmentStateAnswer.test.ts | 21 ++++++++++ .../environment/environmentStateAnswer.ts | 25 ++++++++---- 5 files changed, 72 insertions(+), 30 deletions(-) diff --git a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md index e4daa97024..90cf08eeb3 100644 --- a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md +++ b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md @@ -93,10 +93,14 @@ Use `view: {kind: "environment-state"}` with the manifest's `template_rules` to extract the data those reads observed instead: entity types and identity fields, entities with recorded values, documents, and per-tool render plans checked against the recordings. The state comes only from reads before the -first related write; types, identity and list membership are inferences. +first related write; types, identity and list membership are inferences. The +view also gives each state-changing tool a write plan, served only when a later +recorded read confirms its effect and none contradicts it. `rea environment-serve manifest.json --evidence capture.json` derives that -state at startup, answers read tools from it and serves `data/reset`, -`data/add` and `data/get` on `/agentenv`; write tools are not served. It -replays the capture instead with `--replay-only` or when the state is -unavailable (not derivable, or a catalog over the discovery budget), and its -stopped document's `state` says which applied. +state at startup, answers read tools from it, serves the write tools whose plan +is served, each applying its effect to that state, and serves `data/reset`, +`data/add` and `data/get` on `/agentenv`; other write tools are not served, and +each plan states why in its `reason`. It replays the capture instead with +`--replay-only` or when the state is unavailable (not derivable, or a catalog +over the discovery budget), and its stopped document's `state` says which +applied. diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 415b740c29..d1b0c8e01e 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -375,8 +375,11 @@ In replay, `extensions`, `operations` and `children_environments` are fixed to `null`, `[]` and `null`: a replay environment declares no extensions, no data-plane operations and no children. Served [from the state](#serve-from-the-state), the card declares the three data-plane -operations and one intake extension instead, leaves write tools out and -describes each tool as answering from the state where the state backs it. +operations and one intake extension instead, and the catalog holds the read +tools and only the write tools whose effect the capture confirmed: a read tool +is described as answering from the state where the state backs it, and a +served write tool by the effect it applies to the state and the body members it +accepts. ## Serve from the state @@ -419,7 +422,7 @@ Each read tool answers according to its view in the state: | View | Answer | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `item` | The entity the path arguments name, rendered into the recorded envelope of `envelope_ordinal`. Arguments match as replay matches them: each collection segment equals the held collection's segment as written or after percent-decoding, and the identity is the last argument percent-decoded, then as written; values are never re-split on `/`. Any entity the state holds answers, including one `data/add` loaded; query arguments only decide `replay.exact_match`. An entity the state does not hold answers the tool's latest recorded 400, 404 or 410 response with `state.view` `missing`, or is a tool error when none was recorded; one lacking a field every recording carried is a tool error; a body is never invented. | -| `list` | When the arguments name one of the lines the view checked exactly (its latest recordings before the first related write; every path and query value equal to the recording's, and every recorded query parameter supplied), that recorded envelope with its array rendered from the state: every entity of the collection in state order for `membership: "all"`, or the recorded members still held. A call with no query argument to a `membership: "all"` view answers from a checked line that only pages its list. A member lacking a field every recording carried makes the call a tool error rather than a partial list. | +| `list` | When the arguments name one of the lines the view checked exactly (its latest recordings before the first related write; every path and query value equal to the recording's, and every recorded query parameter supplied), that recorded envelope with its array rendered from the state: every entity of the collection in state order for `membership: "all"`, or the recorded members still held. A call with no query argument to a `membership: "all"` view answers from a checked line of the collection its path arguments name that only pages its list, with `replay.exact_match` false. A member lacking a field every recording carried makes the call a tool error rather than a partial list. | | `document` | When the arguments name the document's checked line exactly, the state's value for it; a tool error when the state holds none. | | `recorded` | The recording, as replay answers it. | @@ -436,8 +439,9 @@ stay served and answer from their recordings with the reason `query-body`. `POST`, `PUT`, `PATCH` and `DELETE` tools are served only when the state's [write plan](web-network-captures.md#environment-state) for them is served: the capture's later reads confirmed the effect the plan models. Other write -tools are not served, so no call claims a change it does not make. A served -write changes the live state, which every MCP session and `data/get` then see: +tools are not served, so no call claims a change it does not make; each one's +plan states why in its `reason`. A served write changes the live state, which +every MCP session and `data/get` then see: | Effect | Call | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -582,14 +586,17 @@ The card declares the operations and what `data/add` accepts: } ``` -The state is a projection of recorded reads. List and document calls answer -from the state only for the lines their views checked, and every recorded view -answers from recordings; item calls answer for any held entity but render only -the envelope one recording carried. Values copied outside entity objects, -derived values and counters other than those that count a list stay as -recorded; no write has an effect. An exported bundle carries no state file: -its server derives the state at startup, as the export does to predict what it -serves. +The state starts as a projection of recorded reads and changes only through +served write tools and the data plane. List and document calls answer from the +state only for the lines their views checked, and every recorded view answers +from recordings; item calls answer for any held entity but render only the +envelope one recording carried. Values copied outside entity objects, derived +values and counters other than those that count a list stay as recorded, and a +served write changes only the one entity or document it writes: side effects +on counters, relations and copies of entities in other collections, reordering +after an update and referential rules on delete are not modelled. An exported +bundle carries no state file: its server derives the state at startup, as the +export does to predict what it serves. ## Export a bundle @@ -676,8 +683,9 @@ The README written into the bundle records the capture artifact's SHA-256, the capture and manifest Evidence ids, the manifest view digest, the served tool count, the derived state's Evidence id or that no state is served, and the package source. It says what the environment serves: read tools from the state -the capture observed, the `/agentenv` data plane on it and no write tools yet, -replay when the state is unavailable, and `--replay-only` for pure replay; it +the capture observed, the `/agentenv` data plane on it and the write tools +whose effect the capture's later reads confirmed, replay when the state is +unavailable, and `--replay-only` for pure replay; it says which applies to this bundle, with the error code and message when the state is unavailable, and that neither is emulation and served content is untrusted data. It states that the recorded content belongs to the diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index 86e2fd0727..0e85d00919 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -52,7 +52,7 @@ export interface EnvironmentServedCheck { export interface EnvironmentBundlePlan { readonly name: string; readonly packageSource: EnvironmentPackageSource; - /** Tools the bundle serves: every manifest tool for replay, the read tools from a state. */ + /** Tools the bundle serves: every manifest tool for replay; from a state, the read tools and the served write tools. */ readonly toolCount: number; readonly check: EnvironmentServedCheck | null; /** Whether the bundle serves from the state, as `environment-serve` reports it. */ diff --git a/src/application/environment/environmentStateAnswer.test.ts b/src/application/environment/environmentStateAnswer.test.ts index cc4d9e588f..91888612a5 100644 --- a/src/application/environment/environmentStateAnswer.test.ts +++ b/src/application/environment/environmentStateAnswer.test.ts @@ -265,6 +265,27 @@ describe("list answers", () => { }); }); + it("answers an unfiltered call from a paging-only line of the parent its path arguments name", () => { + const books = servingFrom([ + ["GET", "/api/orgs/1/books?limit=50", [{ id: 1 }, { id: 2 }]], + ["GET", "/api/orgs/2/books?limit=50", [{ id: 3 }]], + ["GET", "/api/orgs/3/books?limit=50", [{ id: 4 }]], + ]); + expect(answer(books, "get_orgs_books_by_id", { id: "2" })).toMatchObject({ + body: [{ id: 3 }], + replay: { record_ordinal: 1, exact_match: false }, + state: { + source: "state", + collection: "/api/orgs/2/books", + membership: "all", + listed: 1, + }, + }); + expect(answer(books, "get_orgs_books_by_id", { id: "9" })).toMatchObject({ + state: { source: "recording", reason: "unrecorded-request" }, + }); + }); + it("refuses a list whose member lacks a field every recording carried, never answering a partial body", () => { const serving = servingOf(); load(serving, (entities) => diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 2ffc0b93e4..22b1f64196 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -362,7 +362,7 @@ const checkedLine = ( return selectReplayExchange(tool, lines, args)?.exchange; }; -/** For a list of a whole collection, a call that supplies no query argument answers from a checked line that only pages it; a requested page answers from its recording. */ +/** For a list of a whole collection, a call that supplies no query argument answers from a checked line of the collection its path arguments name that only pages it, chosen as replay chooses; a requested page answers from its recording. */ const unfilteredLine = ( source: EnvironmentSource, tool: EnvironmentManifestTool, @@ -370,14 +370,23 @@ const unfilteredLine = ( args: ReplayArguments, ): RecordedExchange | undefined => { const query = environmentToolArguments(tool).query; - if ([...query.values()].some((argument) => args[argument] !== undefined)) + const segments = callSegments(tool, args); + if ( + segments === undefined || + [...query.values()].some((argument) => args[argument] !== undefined) + ) return undefined; - return checked - .map((ordinal) => source.exchanges.get(ordinal)) - .find( - (exchange): exchange is RecordedExchange => - exchange !== undefined && pagesOnly(exchange.query ?? undefined), - ); + const lines = new Map(); + for (const ordinal of checked) { + const exchange = source.exchanges.get(ordinal); + if ( + exchange !== undefined && + pagesOnly(exchange.query ?? undefined) && + collectionMatch(collectionPathOf(exchange, false), segments) !== undefined + ) + lines.set(ordinal, exchange); + } + return selectReplayExchange(tool, lines, args)?.exchange; }; type StateDocumentView = Extract;