Files
rea/docs/specs/agent-environments/state-serve/spec.md
Edgar Arakelyan 166fb0820f feat(environment)!: serve the environment state by default (#5351)
* feat(environment): extract the initial state a capture's reads observed

Add the environment-state analysis view. From the parents the environment
manifest accepts, it extracts the data the capture's reads observed before
the first related write: entity types with an identity field (proved by a
path parameter or inferred from an identity name), entities with their
recorded values and provenance, documents for written non-entity paths, and
a render plan per read tool that is kept only when it reproduces the tool's
latest initial recordings from the state; every other read tool is a
recorded view with the reason. The Evidence is always inferred.

Response-body decoding moves to a domain owner shared with replay, exact
JSON parsing moves to the owner of the exact-number policy, the manifest and
state views share parent admission and record grouping, and
projectAnalysisView takes its accumulators as one object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): bound state at redacted writes; infer identity only from items

A scenario write whose URL was redacted has no request line, so it was
dropped and reads after it counted as initial state. Such a successful
write now joins the timeline with an empty path, whose root relates to
every type, so it ends every type's state; a limitation counts them.

A list whose recordings were all empty passed the "every listed item
carries it" check vacuously and published an identity field nothing
showed; it now needs at least one item, else the view stays recorded
as no-identity.

A value that differed between two request lines of one item tool, such
as a query variant, was called volatile, and the gate then substituted
each recording's own value; only repeats of one request line now show
volatility, so differing lines go through fields-differ.

List membership is compared only while a list can still be the whole
collection, and merged entities are indexed by type and collection once,
so paged captures no longer cost pages times entities (8,000 pages of
40,000 entities: 5.7 s to 0.23 s).

Docs and the tool contract say scenario state keeps the capture's
redaction markers and how redacted writes bound it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep first sightings, one list pointer and linear field unions

Entities took the order of the first plan observed, and item plans run
before list plans, so a complete list read before its detail reads came
out reordered and lost "all" membership; each entity now keeps its
earliest recorded sighting.

A list plan reused the first recording's pointer for later recordings,
so a later response that was itself an entity wrapping a list was read
as that list; every recording must now locate the same list.

The field union scanned its ordered list for every key, quadratic in an
object's width; a set now answers membership.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep post-write values and partial lists out of the initial state

A value that changed between repeats of one request line was named
volatile whenever no write under the type's own root lay between, so a
write under another root (for example POST /api/dashboards/db before a
second GET /api/search) left its effect in the initial state. Any
recorded write between the two observations now leaves every tool of the
type recorded with reason fields-differ instead.

A list was published with membership "all" even when a total or count
envelope member exceeded the number it listed. Such a counter now makes
the membership "recorded".

Also drop a stale doc comment on ObservedEntity.order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): scope item-shaped and deleted written paths out of documents

State documents are held only for list-shaped paths a POST, PUT or PATCH
tool writes. Reads of item-shaped written paths and of paths that are only
deleted stay recorded views; the guide now says so and the spec defers them
to a later increment of #4933.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): count writes in flight when a value changes between reads

The volatility check compared write start positions with the two reads,
so a write already in flight when the earlier read started was missed and
its effect was named volatile. A write now counts when its interval
overlaps the span from the earlier read's start to the later read's end.

The check also ran once per changed field and scanned every write. It now
runs once per pair of observations, answered by a binary search over the
writes in timeline order and the latest end among each prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): catch cross-tool changes across writes; read only item totals

A value that changed across a write was caught only between repeats of
one tool's request line, so a list read before a write and an item read
after it put the post-write value into the initial state. Changes are
now classified per entity in timeline order after observation: across
any recorded write, a change between repeats of a line or between two
lines of the type rewrites the type (every tool recorded as
fields-differ); with no write between, a repeat's change is volatile.
The classification lives in environmentStateMerge.ts.

Any numeric member whose name contained "total" or "count" counted as
an item total, so total_pages, account_id or page_count made complete
lists partial. Item totals are now members whose name tokens include
total or count and none of page, pages or per; a string of digits counts
as a number; and a set has_more, next or next-page token member also
marks the list partial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep post-write observations out of initial state

* fix(environment): retain field provenance and bound state inference work

* feat(environment): serve tool calls from the environment state

`environment-serve --state <state.json>` serves an environment from the
initial state an environment-state view extracted. Read tools whose views
are state-backed answer from a live state store: item views render the held
entity into the recorded envelope, list views render the collection or the
recorded members, and document views return the held value; a missing entity
or one lacking a required field is refused, and requests the state cannot
answer fall back to their recordings with the reason. The /agentenv data
plane implements data/reset, data/add (a rea-state/v1 document as a data
part, base64 bytes, a file:// path or an https URL, validated and replaced
atomically) and data/get, and the card advertises those operations and an
intake declaration. Stage 1 is read-only: write tools are not served in
state mode. Without --state nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(environment): record verification after merging the state view's review fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(environment)!: serve the environment state by default

environment-serve derives the environment state at startup by running the
environment-state view in-process on the capture with the manifest's
template rules, so an environment comes with its state and a user supplies
nothing extra. The derived Evidence is the same one inspect-analysis-view
produces by hand. --state is removed; --replay-only serves exactly the
stateless replay of main. When the state cannot be derived, or its catalog
does not fit the discovery budget, the server starts in replay mode, logs
the reason and reports it in the stop summary's `state`. Exported bundles
serve the state by default too: export derives it the same way, so the
verifier's tool count and checked answer match what is served, and the
README describes the served mode.

BREAKING CHANGE: environment-serve answers read tools from the derived
state and does not serve write tools unless --replay-only is given; the
stop summary reports `state` instead of `state_evidence_id`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): bound data-plane replies, keep batch order, cancel intake on stop

- One /agentenv response is held to 64 MiB: replies are measured as they
  are produced and one past the budget becomes a -32000 error stating it.
- Every message of a batch enters the arrival-order queue on arrival, so
  another request's reset never lands between them.
- Stopping the server, or a caller leaving, cancels the active download
  and answers queued calls `cancelled` without running them.
- file:// URIs name only this host: another host or a network share is
  refused before anything is opened.
- A loaded view value may not give an entity another identity.
- List answers refuse a member lacking a field every recording carried,
  as item answers do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep bodies too deep for the state's nesting out of the state

A recorded body at the JSON depth limit was accepted by extraction, then
nested a few levels deeper as a document or entity value, so sealing the
state Evidence threw and environment-serve and environment-export stopped
instead of falling back. stateBodyJson, the one rule extraction and
serving share, now treats a body nesting deeper than the limit minus 16
levels as unusable, so its tool stays a recorded view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): measure data-plane replies before building them

83fcc739 bounded one /agentenv response to 64 MiB, but it still
serialized each reply whole before measuring it, so a batch of data/get
calls on a large state built every refused reply in full only to drop
it. Each reply is now measured in bounded JSON parts (the shared
jsonParts encoder), its parts kept only while it fits; a reply past the
budget is counted for its exact reply_bytes but never assembled. A test
checks that a refused read is never handed to JSON.stringify whole and
that its reply_bytes is exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): release batch replies as they are encoded; judge exact numbers by value

answerBatch kept every settled reply in its pending array until the
batch ended, so a batch alternating data/add and data/get held each
loaded state alive; each reply now leaves the queue as it is encoded.

The exact-number policy judged a number by its recorded spelling, so a
state loaded with 1e20 came back from data/get as
100000000000000000000, which data/add refused, breaking the promise
that data/get output loads back unchanged. A number is exact only when
both its recorded spelling and the one serialization writes are safe,
so integers from 2^53 below 1e21 are refused in any spelling; replay
keeps such bodies as recorded text. A domain test checks that every
accepted number is accepted again once written back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep room for refusals and framing in the reply budget

The response budget counted accepted replies only: a budget error that
replaced a reply, and a batch's brackets and commas, were not charged,
so near 64 MiB later refusals pushed the body past the advertised
limit. Every reply still to come now keeps room for its own budget
error, and a batch keeps room for its framing, so a refusal always fits
and the body never passes the budget. Reply ids and which messages are
answered come from one place shared by the replies and the reserve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): refuse a loaded state that data/get would write past the intake limit

data/add measured only the document it was given, while data/get
writes the state back with the members a document may omit (types, the
state Evidence id, view fields and record citations). A document just
under 32 MiB could therefore load and be reported larger than any intake
accepts, breaking the data/get then data/add round trip. replace now
measures the state as data/get writes it, part by part, and refuses it
past the limit, leaving the state unchanged. The limit moves to the
state document module, so the application store can read it; the
intake re-exports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(environment): keep merged export checks on the shared test graph

Main's export tests installed module mocks for the whole services
project, which reuses one module graph. A tarball read failure then
missed its importer, and a one-shot heap mock could be taken by another
file. Read those through bindings the tests replace for one case.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(environment): keep a state file whose close failed owned until it closes

A file:// data/add that could not close its descriptor dropped the handle,
and an admission failure whose cleanup close failed dropped its owner, so
nothing could retry the close or report it.

The read now runs through withOwnedRegularFile, and a failed close is
retained in a server-lifetime ArtifactResourceScope. The next file read
retries it first and answers -32000 while it is still open; inline and
https documents still load. Closing the served environment retries it again
and rejects with the unfinished close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-22 05:04:57 +08:00

13 KiB

Serve the environment from its state by default

Issue: https://github.com/morluto/rea/issues/4933

Outcome

rea environment-serve <manifest> --evidence <capture> serves the environment from the initial state the capture observed, with nothing else to supply: at startup it derives the environment-state view of the capture in-process with the manifest's template_rules. Read tools whose views are state-backed answer from one live state, the /agentenv data plane implements data/reset, data/add and data/get against it, and the card advertises those operations and an intake declaration. --replay-only serves exactly the stateless replay main serves: every manifest tool replays its recordings, /agentenv answers every method as not found and the card is byte-identical. A state that cannot be derived, or whose catalog exceeds the discovery budget, starts the server in replay mode, logs why and reports it. Exported bundles serve the state by default, and their verifier and README match what they serve. Serving is read-only in this increment: write tools are not served from the state.

Requirements

  • Derive the state in the application layer, shared by serve and export: deriveEnvironmentState(source, capture, operation) authenticates the capture value the command already read (an owned snapshot authenticated by the source load is reused, not parsed again), projects the environment-state view with the manifest's template_rules through inspectAnalysisViewValidated with the capture as retained Evidence (the Evidence id equals that of a projection by hand, and the capture's strings are not charged again as inline input, which refuses captures of a few MiB), then applies loadEnvironmentState: inspect_analysis_view Evidence of kind environment-state, its parent_evidence_id equal to the capture's, its template_rules equal to the manifest's in the same order, and every view naming a manifest tool, refusing otherwise with invalid_request whose issue path starts with state. The source validates a HAR capture with the owned borrowing parse, so the view reuses that validation.
  • prepareServedState(source, capture, {name, operation}) first builds the state catalog under the served name, then derives the state, and reports {served: true, evidence_id} or {served: false, reason: "unavailable", code, message} with the refusal's projected code and message: a state catalog over the discovery budget is unavailable with resource_constraint, and no state is derived for it. A derived state whose catalog fits always serves from the state. The CLI reports {served: false, reason: "replay-only"} itself for --replay-only and derives nothing.
  • Keep one state store per server process, shared by every MCP session and the data plane: entities keyed by type, collection and id with per-collection order taken from the entities array, documents keyed by tool. reset restores the Evidence state, replace installs a validated document whole, and mutations are synchronous.
  • Answer item views by rendering the entity the path arguments name into the recorded envelope of envelope_ordinal. Match arguments as replay does: a held collection segment equal to the argument as written or after percent-decoding, preferring verbatim spellings, and the identity as the last argument percent-decoded, then as written, never re-split on /. Any held entity answers, and query arguments only set replay.exact_match. Refuse an entity the state does not hold, or one lacking a required field, as a tool error and never invent a body.
  • Answer list views from the state only when the arguments name one of the view's checked_record_ordinals exactly (every path and query value equal and no recorded query parameter unsupplied), choosing among them as replay chooses: every collection entity in state order for membership: "all", otherwise the recorded members the state still holds. A member lacking one of the view's required_fields makes the call a tool error stating that the state holds no complete member, never a partial body, as for items. Lines recorded after a write or with other filters were never checked and answer from recordings.
  • Answer document views with the state's value when the arguments name the document's checked line exactly.
  • Answer recorded views, tools without a view and unrecorded requests from the recording, labelled state: {source: "recording", reason}; label state answers state: {source: "state", view, …} with the base record's replay provenance and truncated: false; refuse a state answer over 256 KiB as a tool error rather than cut it.
  • Serve data/reset ({}), data/add with exactly one data or file part (bytes, a file:// regular file or an https:// download with a 30 s timeout covering the body, no redirects and a streamed 32 MiB cap; mimeType when given is application/json) and data/get ({parts: [{kind: "data", data: rea_state}]}). A file:// uri whose host is neither empty nor localhost, or whose path is a UNC path, is -32602 before anything is opened. A document that data/get would write back past 32 MiB, once it adds the members a document may omit, is -32602, so a reported state always loads again. Anything else is -32602, including empty parts; a failed download is -32000; errors never echo a uri or content and name a coarse failure category in error.data.reason. Calls run one at a time in arrival order across requests; every message of a batch is queued when the batch arrives, so no other request's call lands between them, and notifications run without a reply.
  • Stopping the server (close or prepareShutdown) aborts the data plane's stop signal, and a request's own signal aborts when its client leaves: the download in progress ends with reason cancelled, queued calls are answered -32000 with reason cancelled without running, and a data/add whose file read finished after the cancellation does not replace the state.
  • A file:// read whose descriptor close fails answers -32000 and keeps the descriptor in a server-lifetime owner. The next file read retries the close first and, while it still fails, answers -32000 without opening the file. close retries it again and rejects while it is still open.
  • Bound one /agentenv response to 64 MiB of replies (twice the intake limit). Each reply is measured in bounded JSON parts as it is produced, in message order, and assembled only when it fits, so a reply past the budget is counted but never serialized whole. Room is kept for every later reply's budget error and for a batch's brackets and commas, so the body never passes the budget; a reply that does not fit is replaced by -32000 with data: {max_response_bytes, reply_bytes}, unless that error is no smaller than the reply, so the body is never built past the budget and small write replies are kept.
  • Validate rea-state/v1 documents against the served types and views: known types, collections that instantiate their template, ids equal to the identity field, unique keys, view_fields naming item or list views of the type without setting the type's identity field to a value whose scalar text differs from the entity's id (refused at that view's fields), documents naming document views once at the initial path, and the optional state_evidence_id and types that data/get reports equal to the served ones. Refuse numbers without an exact native value and __proto__ members. Name the first problem by JSON pointer, never deeper than a caller-data member (fields, value, types), and leave the state unchanged.
  • Keep record citations Evidence provenance: refuse record_ordinals or a record_ordinal other than the Evidence's for that entity or document, and hold the Evidence's citations only for loaded values equal to the Evidence's; edited or added values cite none.
  • In state mode, declare operations: ["data/reset", "data/add", "data/get"] and the urn:agentenv:intake/v1 extension, describe tools as answering from the state where it backs them, and leave out POST, PUT, PATCH and DELETE tools other than query POSTs. The discovery budget reserves the card and descriptions actually served (servedCatalog returns the tools and the data plane the card declares). A state catalog can exceed the budget a replay catalog fits, so such a state is unavailable and the environment and its export serve replay: everything main served and exported is still served and exported.
  • The CLI has no --state; --replay-only (default false) serves replay without deriving. Otherwise it prepares the state under the name it serves (ENVIRONMENT_NAME or the manifest's). When the state is unavailable the command logs a warning with the code and message through its listener logger before binding, and serves replay. The stopped document's state member is the report. serveEnvironment keeps taking an optional prepared state and serves replay without one; the served catalog (servedCatalog) and the answer function (environmentToolAnswer) are shared with export.
  • environment-export keeps the bundle command rea environment-serve /env/manifest.json --evidence /env/evidence.json and, after its name checks, prepares the state under the bundle name as that server will, to predict what it serves, so a refused --name derives nothing; its report is served or unavailable, never replay-only. The verifier's TOOL_COUNT and the command's tool_count are the served tool count, and its check tool and expected status come from calling the server's answer function with {} on the first served GET tool without path parameters whose answer is not an error. The verifier and smoke task use the id serve. The README's "What the environment serves" section describes serving from the state, the replay fallback and --replay-only, says which applies to the bundle (with the code and message when the state is unavailable), and keeps the not-emulation and untrusted-content caveats. The export output adds state. The hosted-lane verifier script expects the served count.

Acceptance and limits

Services tests cover derivation (the by-hand Evidence id and state, a capture the manifest was not projected from, a capture inline admission refuses), the prepared reports (served, unavailable for a capture_browser_scenario capture with header values but no response bodies, unavailable with resource_constraint for a 350-tool read-only manifest whose replay catalog fits), bundle plans served from the state and in replay (tool count, check tool and status, a check whose state answer differs from replay's in record and status, a check that skips a tool the server cannot answer, the replay fallback of that 350-tool manifest, name refusals before any preparation, a manifest neither mode admits, verifier, smoke task and README wording), the loader refusals, store reset, order, round trip and atomic replace, the first-problem pointers, citation provenance after a load and every answer path: item (including percent-encoded collection segments and ids), list all, list recorded, lines recorded after a write or with other filters, a line replay's tie-break would not pick, document, recorded view, tool without a view, unrecorded request, missing entity, missing required field and the body limit, a view value that changes an entity's identity, and a list member missing a required field. Adapters tests cover the data-plane shapes, -32602 for empty and malformed parts and invalid documents, inexact numbers, bytes, file:// and its failure categories, file:// uris naming another host or a UNC path, injected https downloads (status, declared and streamed size, the timeout whether or not the fetcher honours its signal), the default fetch's refused redirects, arrival-order application across requests, a batch another request's reset cannot split, cancellation by the server's stop and by a departed caller, a server that closes while a download is stalled, a retained state-file close that the next file read and close retry, the response budget, batches and notifications, the card declaration, absent write tools, a served query POST, the per-mode discovery reservation and a data/add that changes the next tool answer over MCP. The mcp-boundary tests serve the state the built CLI derives from only a manifest and a capture, and --replay-only with every manifest tool, -32601 data-plane answers and a card whose SHA-256 equals that of the card main serves for the fixture; the process-boundary test checks the stopped document's state for a derived state, --replay-only and an unavailable state, and the warning logged before binding; the cli-boundary export test checks the served tool count, the verifier and the README of an exported bundle. No test reaches an external network. Write effects, list filters beyond recorded lines and tables in the intake declaration are out of scope.

Deferred issue criteria

Write effects are a later increment of #4933. This PR must reference rather than close the issue.