# Mock: showing a page in the state a reviewer asked for
The states that most need a visual review are the hardest to reach on a
preview: empty, error, forbidden, loading, one item, a thousand items. Maple
Mock lets a reviewer name the state, and rewrites the page's API responses into
it. The tracking issue is #166; the open questions are in Discussion #174.
This file records decisions as they land. It says what exists, not what is
planned.
## A model picks, code writes every byte
A model may be asked which state was meant and which calls it concerns. It is
never asked to write a response body. A transform reshapes a response the page
has already received, or one sampled from its schema, so the app's own parser
still accepts the result. A generated payload would be valid only by luck.
Nothing in the runtime calls a model.
## Where the pieces live
| Piece | Home |
| ----------------------------------------------- | ------------------------- |
| The recipe type and its validator | `@maple-kit/core/mock` |
| Interceptor, codecs, transforms, recipe storage | `@maple-kit/mock` |
| The one-line install | `@maple-kit/mock/install` |
| The box's controller, framework-free | `@maple-kit/mock/client` |
| The recipe a server request carries | `@maple-kit/mock/server` |
| `useMock()` | `@maple-kit/react/mock` |
| `MapleMock`, the box and its banner | `@maple-kit/ui/mock` |
**A package, not a core subpath.** The interceptor is imported from a host's
entry before its first request, and a host that does not mock should not carry
it. A host that only mocks installs `@maple-kit/mock` and never loads the review
overlay.
**The recipe type is in core** because the comment fence carries it, and the
fence is core's. The writer and the reader of a format share one validator and
ship in one release, which is the lesson of a reader pinned to an older core
that read a newer format and passed.
**`@maple-kit/mock` has no React and no Effect**, and never imports
`@maple-kit/ui` or `@maple-kit/react`. They depend on it through their own
`./mock` subpaths. A lint rule enforces the direction.
## The recipe
```ts
{
version: 2,
calls: [{ key: "trpc:project.list", state: "empty" }],
flags: { "new-dashboard": false },
as: { role: "billing-manager", permissions: { "invoice:void": false } },
route: "/projects",
request: "mock this page with an empty state",
}
```
- **A call is named by a key its codec owns**, prefixed with the codec's name:
`rest:GET /api/projects/:id`, `trpc:project.list`. Core checks only the
prefix; the grammar after the colon is the codec's.
- **A call not named passes through.** A mock is a list of exceptions to the
real server, never a replacement for it. Mutations are no different: one is
mocked only if the recipe names it.
- **The states are a closed set**:
`empty`, `error`, `forbidden`, `loading`, `one`, `many`, `long`, `sparse` or `mixed`. A state outside it is refused
rather than ignored, since a recipe that silently mocks less than it says is
worse than one that fails. A new state is appended, so the order a box lists
and a plan breaks ties in holds. `long`, `sparse` and `mixed` came after
0.9.0 without a version bump: no released reader has seen a version-2
recipe, and a 0.9.0 reader refuses the unknown state, which is that same
preferred failure.
- **Unknown fields are dropped, a newer version is refused.** Dropping lets a
later layer be added to the record without an older reader rejecting it.
Refusing a newer version stops a reader half-applying a format it does not
know.
- **`flags` answers flags with the values named**, any JSON value, keyed by
the flag's own key. A flag not named keeps its real value.
- **`as` is who the page is told the reviewer is**: a `role`, and
`permissions` granted (`true`) or taken away (`false`) beside the ones the
reviewer has. Both are the host's words; Maple has no list of roles, since
every app has its own. The server still acts as the reviewer, so a mutation
sent under `as` really happens.
- **Version 2 is always written, and version 1 is still read.** `flags` and
`as` change what a recipe does, and a reader that dropped them would mock
less than the recipe says. A released build refuses a version 2 recipe
rather than showing half of it.
- **`route` scopes it to one route pattern**, such as `/projects/:id`. A
recipe kept for the tab would otherwise follow the reviewer to every page,
and empty a list they never asked to see empty. Absent, it applies
everywhere.
- **`request` is the reviewer's words**, kept for display. Nothing reads it to
decide what to mock.
`parseRecipe` returns a fresh copy and throws `InvalidRecipeError` listing
every problem at once.
## Applying a mock reloads the page
The recipe is written to `sessionStorage` and to the `?maple-mock=` parameter,
and the page reloads. Patching a client's query cache in place would mean one
integration per data library, each of them fragile; a reload makes every
library fetch again through the interceptor.
- **The link wins over the tab.** Opening a shared link shows what was shared,
even in a tab that had another mock on.
- **The tab keeps it through navigation** that drops the query string.
- **The parameter is base64url of the JSON**, so it survives any router that
re-encodes the query.
`readRecipe` throws on a recipe it cannot read rather than returning nothing,
so the caller decides whether to drop it. A broken link that silently shows
real data looks exactly like a working mock of a page with real data.
## The interceptor
`import "@maple-kit/mock/install"` from the app's entry, before its first
request, wraps `fetch` and `XMLHttpRequest` in the page. `installMock(options)`
from `@maple-kit/mock` is the same with a logger and an `ignore` for requests
that are not the page's data, such as Maple's own route.
- **No service worker.** It is built on `@mswjs/interceptors`, the layer MSW
runs on, which patches the page's globals. Nothing is served from the host's
origin and `docs/overlay-csp.md` still holds.
- **Preview builds only** is the host's build switch, the same one that keeps
the overlay out of production. `examples/vite-app` imports it behind a
build-time constant, and its `verify` script checks that a production build
carries none of it.
- **With no recipe it changes nothing.** Every request goes through untouched,
and its answer is recorded.
## The MSW transport
`mockHandlers(recipe)` from `@maple-kit/mock/msw`, or `/node`, is the same
resolver as MSW request handlers, for a host that already runs MSW: Storybook,
Vitest, Playwright. It is one `http.all("*")` handler that answers the named
calls and falls through for the rest, so it goes first and the host's own
handlers after it still answer everything else. A forwarded request goes out
through `fetch(bypass(request))`.
It is opt-in, and `msw` is an optional peer. In a browser it needs
`mockServiceWorker.js` on the host's origin, which is the host's CSP decision;
Maple's own path never needs a worker. It records the answers it reshapes but,
seeing no passthrough response, not the ones it lets through. The recipe comes
from the caller, since nobody types into a test.
## Resolving a request
A **codec** takes one HTTP exchange apart into logical calls and puts it back
together: `split(request)`, `read(response)` and `join(calls, answers)`. `read`
is `split` for the response. An optional `prepare(request)` rewrites the
request sent on when its response will be rewritten. Everything a protocol does
differently stays inside those, so nothing else in the package reads a URL or a
header.
The default codecs are tRPC at `/api/trpc`, then REST. `installMock({ codecs })`
replaces them, for example with `trpcCodec({ endpoint: "/trpc" })`.
For each request, the calls the recipe names are answered and the rest keep
the server's answer:
| State | What is sent to the server | What the page gets |
| ---------------------- | -------------------------- | --------------------------------------------- |
| not named | the request, as it was | the server's answer |
| `error`, `forbidden` | nothing | 500 or 403, in the protocol's own error shape |
| `loading` | nothing | nothing, until the request is abandoned |
| `empty`, `one`, `many` | the request, as it was | the server's own answer, reshaped |
| `long`, `sparse` | the request, as it was | the server's own answer, reshaped |
| `mixed` | the request, as it was | the server's own answer, reshaped |
- **A body state reshapes the live answer**, so the mock is as fresh as the
page. When the server fails, the last recorded answer is reshaped instead;
with neither, the server's failure goes through as it came.
- **A failure never reaches the server.** A mutation named `error` is a write
the server never sees, which is the state the reviewer asked to look at.
- **A response the codec cannot read goes through untouched.** A body state on
an HTML page is a mistake in the recipe, not a reason to break the page.
## The REST codec
One request is one call. Its key is the method and the path, with identifiers
collapsed: `rest:GET /api/projects/:id`. A segment is an identifier when it is
all digits, a UUID, a ULID, or sixteen or more hex characters with a digit
among them. The query string is not part of the key.
It is the last codec tried and claims every request that reaches it, but reads
only a JSON response, so a script or an image is never recorded or reshaped.
## The tRPC codec
`/api/trpc/project.list,user.me?batch=1` is two calls, `trpc:project.list` and
`trpc:user.me`. Input is not part of the key. A single call without batching,
a GET query and a POST mutation are all read the same way.
**A partial mock is a splice.** The real batch is still sent, and only the
named calls are replaced in the answer, so the server stays the truth for
everything the reviewer did not ask to change. The batch status is recomputed
as tRPC computes it: one status if every item agrees, 207 if they differ. An
item the mock did not touch is written back byte for byte as the server wrote
it; the suite checks that against tRPC's own server, not against this codec.
**A streamed batch (`httpBatchStreamLink`) is fetched plain and written back
as a stream.** The forwarded request drops `trpc-accept`, so the server answers
one JSON array, and the codec writes the JSONL the client asked for: the head,
failed calls, then each level of the rest, in the order tRPC's own producer
writes them for calls that settle at once. When they do, every line of a partly
mocked stream but the mocked one matches the server's own stream exactly.
When one settles later, the server numbers its chunks in the order they settle,
which a plain answer does not say. The lines then differ in those numbers only,
and tRPC's client decodes every untouched call to the value the server sent.
What is lost is streaming itself: the page gets every call when the slowest has
answered. In the Next example, a call batched beside one that takes 1.5 s
arrives in about 10 ms unmocked and in about 1.5 s with a mock on the batch.
**A stream the page receives unmocked is read without holding it.** Recording
reads a copy of the response after the page has it, because the interceptor
would otherwise wait for the whole stream before handing it over. The copy is
read from the moment it arrives, and kept as far as it got when the page
aborts: tRPC's stream link aborts its request once its last call has answered,
before the stream ends, and a copy still waiting to be read errored with it.
**superjson is read from the answers**, as the `{ json, meta }` envelope.
`meta` says which values were a `Date`, a `bigint` or a `Map`, by path, and a
transform keeps it true: an emptied list drops its items' annotations, a
repeated item repeats them. Referential equalities are kept on an untouched
answer and dropped from a reshaped one. In a stream each line is serialised
whole, so an answer's annotations move under `2.0.0` of its line, and back.
**A failure nothing was fetched for** borrows the envelope the call was last
recorded in. A call never seen, failed without a request, has no answer to
learn from; `trpcCodec({ transformer: "superjson" })` says it up front.
The error written is tRPC's default shape: `code` `-32603` or `-32003`, and
`data` with `code`, `httpStatus` and `path`. An `errorFormatter` that adds
fields is not reproduced.
Subscriptions (`httpSubscriptionLink`) are claimed and never read: only a JSON
response is, and an event stream is not one. The browser's own `EventSource`
never reaches the interceptor at all. A polyfill that reads the stream over
`fetch` or `XMLHttpRequest` does, and its events arrive as the server sends them.
The interceptor cancels its own copy of any body it did not read, since a
stream copied and left open cannot be closed by the page.
## Transforms
Code, with no model. Every value in the output is derived from the input,
never invented. Until `long`, every value in the output was in the input; a
longer text is built from its own characters, so the promise became
"derived", and nothing a transform writes is a word it made up.
- **`empty`** turns each list into `[]` and the envelope keys beside it into an
empty page: a count to `0`, a next cursor to `null`, a has-more to `false`.
- **`one`** keeps the first item, with the same envelope.
- **`many`** repeats the items to fifty and raises a count to match. A repeated
item gets a unique `id`, `_id`, `uuid`, `key` or `slug`.
- **`long`** makes every text as long as the page could really receive: to its
`maxLength` exactly, else four times over (at least 32 characters), in the
shape the value already has, so the page looks like it received a long
value rather than like it broke. Words repeat with a space between, so they
wrap, and without a `maxLength` a text ends on a whole word. Only a value that is already one unbroken run grows as one, for
`overflow-wrap`: a slug or a branch (`fix/chart`) by its own words after a
dash, an address by its local part (at most 64 characters) and a URL by a
path segment after its origin, so both still parse.
A number goes to 1,234,567, never past its schema's bound. Identifiers,
references (`ownerId`), cursors, enums, `const`, `pattern`, dates, UUIDs
and colours are left alone, as is every superjson-typed value. Lists keep
their length.
- **`sparse`** makes everything that may be missing missing. With a schema, a
nullable field is `null` and an optional one is gone. Without one, only what
the recording proves is dropped: a key one item of a list lacks is dropped
from every item, a key one item holds `null` is `null` in every item. A lone
object has nothing to prove anything with, and is left alone.
- **`mixed`** covers every combination that matters instead of adding items.
Each field rotates through what it may be (every enum value, both booleans,
null and set, absent and present, short and long text), the rotations side
by side, so the list grows only to the longest rotation, never to a cross
product, and at most to fifty or `maxItems`. A count is kept. A field set
where no item holds a value borrows another item's, else samples its schema.
Lists are looked for three objects deep, which covers `{ data: { items } }`,
and never inside an item. An envelope key changes only when its value already
has the right type, so a `total` that is a sentence is left alone.
**Without a schema** nothing says a field may be null or which field a list is
keyed by, so a cursor is nulled on faith, and a list keyed by a field outside
the five names above repeats its keys under `many`. `long` also leaves alone a
single lowercase word of up to sixteen characters, which may be an enum, and
a fraction below one or a millisecond timestamp.
`long` and `sparse` walk the whole body, sixteen levels deep; `mixed`, like the
list states, looks for lists three objects deep.
## Shapes
A call's **shape** is one JSON Schema over what its data decodes to, and the
rung it came from: `supplied`, `router`, `validator`, `introspection` or
`sample`. `resolve` and `installMock` take a `shape(key)` lookup, asked only for
a call a body state reshapes. The type is `Shape` in `@maple-kit/core/mock`,
since the route writes it and the page reads it.
**The route serves shapes, per call, to a preview that asked.**
`RouteOptions.mock` is `{ preview, schemas }`: the host's preview build switch,
never `NODE_ENV`, since a preview is a production build; and OpenAPI documents,
each marked `rest` or `trpc`, highest rung first, or a function that reads them
once. `GET {base}/mock/schema?key=…` answers the shapes of up to a hundred keys
and leaves out any it has none for. It answers 404 unless `preview` is true,
and 401 to a reviewer the identity connector does not resolve, when there is
one. The documents are normalised on the first request, not at startup.
**The route lists its calls, on demand.** `GET {base}/mock/calls?q=&cursor=`
answers `{ calls: [{ key, method, path, summary? }], next? }`, twenty to a page,
from the same documents. `q` matches each of its words in the method, the path or
the summary, case aside; `cursor` is the `next` the last page gave. It is gated
as `/mock/schema` is (404 unless `preview`, 401 to an unresolved reviewer) and
counted per reviewer on the plan's `rate`, 40 a minute by default, since a
search runs as the reviewer types (429 past it). With no `schemas` it answers an
empty list. Only the box's "Search all" on the calls asks, and only once it is opened.
**The schema never reaches the page's bundle.** `installMock({ route })` reads
shapes from the route as calls need them, through the real `fetch`, one request
for every key asked in the same tick, and keeps every answer, a miss included,
for the page's life. Nothing under `route` is recorded or mocked. The Vite
example's `verify` checks that neither build carries its `openapi.json`.
**Router types, from `maple mock schema`.** `@maple-kit/cli` wraps
`@trpc/openapi`, which reads a router's TypeScript types statically, runs none
of its code, and needs no `.output()`:
```sh
maple mock schema server/router.ts --out=.maple/schema.json --superjson
```
The document it writes carries `x-maple-mock` (`codec: "trpc"`,
`source: "router"`, and `superjson` when asked), so `readSchemaDocument` hands
the route a document without being told what it is. **It is a preview build
artifact**: written by a step before the preview build and `next dev`, and
never committed, so it cannot fall behind the router it describes. The route
reads it on its first request, and without it has no shapes.
**`@trpc/openapi` is an alpha, and an optional peer.** It is loaded by dynamic
import inside that one command and nowhere else, pinned to one version, and a
missing install says which to add. Its programmatic API is used because the
alpha's own `bin` points at a file it does not ship. It asks for TypeScript
below 7, which holds the repository there while it is the rung this command
stands on. A subscription is not described; the generator does not yet do it.
**Normalising.** A `trpc` document has a path per procedure, `/project.list`,
and the shape is its 2xx answer's `result.data`. A `rest` document is keyed by
method and path, with `prefix` put in front, and a `{param}` segment matches any
one segment of a call's key. The first document that describes a key wins on
structure; the recorded or live answer still supplies the values. The box tags
each call with the rung its shape came from.
**A transform stays inside the schema.** A key is nulled only where it is
nullable (a type list with `null`, OpenAPI 3.0's `nullable`, a union with
`null`), dropped only where it is optional, and otherwise left as it was: an
empty page whose cursor is a required string keeps its cursor. `empty` and
`one` keep `minItems`, `many` stops at `maxItems`, and a repeated item takes
each enumerated field's next value, so `many` shows every status.
**A call nothing has answered is sampled from its schema.** When there is no
live answer and nothing recorded, the shape alone makes one, and the body
state reshapes it. The sample is deterministic, so a screenshot of it is the
same on every reload: `const`, then the first enum value, then an example or
a default, then the lower bound, one item, `"text"`, and fixed dates and
addresses by `format`. It ends a recursive schema eight levels down.
**It is not a dependency.** The sampler and the schema readers are about two
hundred lines in `src/schema/`, against openapi-sampler's whole surface, of
which this needs one function.
**A shape can say the call travels in superjson.** A sampled `date-time` is
then written as a `Date` in the envelope's `meta`, and tRPC's own client
decodes it to one. A failure on such a call is written in the envelope even
when nothing was ever recorded for it.
## The plan
A sentence becomes a recipe through `ClassifierConnector.plan`, an optional
method like `score` and `classify`: a classifier that can plan defines it, and
nothing else says so.
```ts
plan({ request, route, calls: [{ key, summary }] });
// → { state, distribution, confidence, calls: [{ key, concerned, p }] }
```
- **It picks, it writes nothing.** The answer is one of the nine states or
`none`, and a verdict per call. The transforms and the sampler do the rest.
A state joins the plan's vocabulary only with eval cases that measure it.
- **`none` is an answer.** "Make the header blue" names no state a page's data
can be in, and a plan that says so is more use than a guessed `empty`.
- **A distribution, not a verdict**, as in `docs/assist.md`: a sentence
between two states really is between them, and `confidence` says how much.
- **`concerned` is `p` at or above one half**, derived rather than chosen, so
the two cannot disagree. Every call in the request gets exactly one verdict,
in the request's order.
- **A summary is what the call returns**, in schema names and descriptions
where a schema exists. A planner is given the calls' keys and summaries and
never their bodies.
`MOCK_PLAN_STATES`, `stateFromWeights` and `plannedCall` in
`@maple-kit/core/connectors` are for a planner with no probabilities of its
own. `runClassifierContract` checks a plan's shape for any connector that
defines one.
**The route plans, the page never reaches a model.**
`RouteOptions.mock.plan` is `{ classifier, cacheSize?, rate?, flags?: { limit? } }`, and
`POST {base}/mock/plan` takes `{ request, route, calls: [{ key, summary }] }`
and answers `{ plan }`. It is gated as `/mock/schema` is: 404 unless `preview`
is true, 404 when the classifier does not define `plan`, and 401 to a reviewer
the identity connector does not resolve. A blank sentence is answered
`{ plan: null }` without asking anyone. A sentence is at most 500 characters,
and the calls at most a hundred.
**A request stays small, whatever the page.** The calls are every one the page
recorded on the route, so the body would grow with the page: about 15 KB on a
dashboard of thirty calls. `routePlan` gzips it (`Content-Encoding: gzip`, from
`CompressionStream`, so the summaries' repeated field names cost little) and
sends the calls in consecutive batches of at most about 6 KB on the wire, one
request after another. The readings are merged: the calls end to end in
order, the state from the most confident batch, and each flag's and the role's
most certain verdict. The route inflates a gzipped body, capped at 512 KB
inflated, and still reads a plain one; any other `Content-Encoding` is 415.
Each batch counts against the rate limit below. A firewall that measures body
size can still block the route: see "A web application firewall" in
`docs/configuration.md`.
**A plan that fails says so.** A 401 or 403 (a firewall, or a session that
ended) shows "The server refused that" and does not invite a retry; a network
failure or a 5xx shows "Try again". Either way the calls stay listed to set by
hand, and the failure and its status go to the `logger` given to `installMock`.
It shares `/assist`'s budget code, not its budget: a cache of 200 plans keyed
by the whole request, and 40 plans a minute per reviewer. A classifier failure
is logged and answered 502 with nothing of the cause, since a provider's error
can name its key.
**The shapes add to each summary.** Where the shape index describes a call,
the route describes its schema in a line: title or component name,
description, field names, and a list's item in brackets, two levels deep, a
local `$ref` followed. `rest:GET /api/reviews` reads `items [Review: id, repo,
branch, …], total, nextCursor`, and `trpc:user.me` reads `User: id, name,
since`. When the page's words are all in that line, only the line is sent, and
the other way about; otherwise both, with a dash between. Only names reach the
planner; a value never does.
**`maple mock plan` asks a deployed route**, not a model:
```sh
maple mock plan "no projects yet" --url=https://preview.example.com/api/maple \
--route=/projects "--calls=trpc:project.list,trpc:user.me"
```
It prints the recipe the box would put in its draft, `request` included, and
exits 1 with the reason when the gate says nothing: a sentence that names no
state, a plan under the floor, a route that plans nothing (404) or wants a
signed-in reviewer (401). Asking the route rather than a local classifier keeps
the model's key in the deployment, and gives CI the plan a reviewer would get,
schema names included. The gate is one function for both, `readPlan`, which is
why it lives in core rather than beside the box.
**The keyword planner** is `keywordClassifier().plan`, the floor the plan eval
measures against. State words pick the state (`no roasts`, `500`, `skeleton`,
`hundreds of`, `truncated`, `no avatar`, `every status`), and nothing matched
is `none`. "No" before a field a record may lack (`avatar`, `description`,
`owner`…) reads as `sparse`, before anything else as `empty`; "long" alone is
`long`, and "a long list" is torn between `many` and `long`, `many` first.
Every state starts with an equal share of a fixed prior, 1.4 in all, so adding
a state does not drag one matched word's confidence under the gate's 0.4
floor, as a fixed 0.2 each would have with nine. Words the sentence shares with
a call's key and summary pick the calls, a plural folded onto its singular and
a camel-cased key split into words. A sentence sharing no word with any call
is about the whole page, and concerns every call except a REST write and one
whose summary says `mutation`.
**A role or a flag picks no call.** Both planners judge the calls on the
sentence less the listed roles and flags it names, `withoutLayers`: a role as
the keyword planner reads one, a flag by its key's words with the words that
switch it or a listed value beside them, and a one-word key only with one of
those, since alone it may be the page's data. "As a member with the merge
forecast on, and the reviews table empty" is judged as "the reviews table
empty", and concerns the reviews call alone; left whole, jev read the role and
the flag as the whole page and put the session and the audit log in the state
too. jev still judges the state on the whole sentence, and gets the rest as
`data` beside it: "no roasts yet" alone reads as not yet a state. A flag named
in other words is left in, and the call question says who the page is shown as
and what is switched concern no call.
**The eval** is `evals/mock-plan.eval.test.ts`: 139 sentences against seven
pages' calls, scored on state accuracy, on the F1 of the calls a plan
concerns, and, on the three pages with flags and roles, on whether the flags
and role it sets are exactly the ones meant. It is scored per set: the 65
data cases, where jev must beat the word list on state and calls; 38
cases written for flags and roles, where it must beat it on the layers; and
36 written for `long`, `sparse` and `mixed`, where it must beat it on calls,
since the word list's patterns for those three were tuned on them.
`evals/cases/mock-plan/README.md` has the numbers, where the cases came from,
and why that flatters the word list.
It reads no grammar: "no errors" is `empty` and `error` at once, and two
states named equally come out torn between them, which is the honest answer
for a word list.
**jev plans in one request.** `jevClassifier().plan` sends the sentence, the
route and the calls as the `state`, and asks one `choice` over the seven plan
states, described by `MOCK_PLAN_STATE_DESCRIPTIONS`, and one `noul` per call:
does the sentence concern `calls[i]`? The probabilities are jev's own: the
state's distribution is the choice's, and each call's `p` is its `noul`. A
route with no calls asks only the state. The questions are keyed by index,
`call:0`, since a call key is any text.
A plan is a keystroke's judgement like a score, so there are no retries, and
`timeoutMs` (8 s by default) abandons one the endpoint never answers.
**The plan learns flags and roles.** The box lists the flags the page
evaluated, `{ key, type, variants? }` from `seenFlags()` and never a value,
and the route adds the roles from its own identity rules, never the page's.
The route drops a flag with no values a plan could set it to, and keeps an
answer to what was listed: a flag, a value or a role nobody listed is
dropped, whatever the planner said. A plan answers one `PlannedFlag` per
listed flag, `{ key, value, concerned, p }` with `value` one of its values,
and a `role` when the sentence names a listed one.
- **A page's flags are never refused.** The route drops a flag a plan could
not set on its own (more than 20 variants, a value over 100 characters, an
unknown type, a bad key) and counts why. Past `MockPlanOptions.flags.limit`
(100 by default; each flag is its own model question, so the cap budgets
work per plan) it keeps the flags whose key shares a word with the sentence
first, keys split on `-_.:/` and camelCase, then the latest sent. The plan
answers `warnings?: string[]`, plain sentences such as `flags: planned 100
of 187` or `flags: 2 dropped (too many variants)`, which the box shows as
one muted line under the field. Only a `flags` that is no list is a 400.
- **The gate carries them.** Named flags and a role at even odds or better
ride on each state's reading, or make a reading of their own when the
sentence names no state: "as a barista" sets the role and the flag, not
"That doesn't name a state".
- **The keyword planner** sets a flag when every word of its key is in the
sentence (a camel-cased or kebab key split, a ticket prefix like
`ROAST-2210-` dropped), on unless "no", "without", "off" or the like sits
next to it, and a variant only when the sentence names exactly one. A role
is read only after "as", "for", or before "view", "sees": "the owner column"
names no role. It never reads a key out of the sentence.
- **jev asks for them in a second request**, sent beside the first: one
`choice` per flag over its values and `leave-unchanged`, and one over the
roles and `no-role-named`, with the sentence, every flag's values and the
roles as that request's `state`. The first request is exactly what it is
without them. A host role named like the person typing, `reviewer`, is read
poorly, since every question calls that person the reviewer; the Vite
example's roles are `owner`, `member` and `guest` for that reason. Putting the lists in one shared `state` cost the data cases
about two points of state accuracy, measured, which is why it is not done.
## Who the reviewer is
Maple never guesses which of a page's fields carries authorisation. The host
names it in `RouteOptions.mock.identity`:
```ts
identity: {
call: "rest:GET /api/session",
role: { path: "role" },
permissions: { path: "permissions" },
requires: {
"rest:GET /api/audit": { roles: ["owner", "auditor"] },
"rest:DELETE /api/roasts/:id": { permission: "roast:delete" },
},
}
```
- **`call` says who the reviewer is**, and `path` is a dotted path into its
answer. The permissions field is a list of names or an object of booleans.
- **The words are the host's.** A field's `values` are the ones given, else
the call's shape's: a role's `enum`, a list's item `enum`, an object's
property names. Every role and permission `requires` names is added.
- **`requires` says what a call needs**: any one of `roles`, or `permission`
granted. It is what turns a call into the page's own 403 under `as`.
`GET {base}/mock/identity` serves the rules with each vocabulary filled in,
gated as `/mock/schema` is. A preview that declares none answers
`200 { "identity": null }`, so a page's console shows no failed request; 404
means the mock is off (`preview` is false).
**A server render can be the source instead of a call.** Many apps never call
a session endpoint: the root layout reads the session and seeds the client with
its roles, so there is no answer for the interceptor to read or rewrite. Give
`mock.identity` a `read` in place of `call`:
```ts
identity: {
read: async (request) => {
const session = await getSession(request);
return session ? { role: session.role, permissions: session.grants } : null;
},
roles: ["owner", "barista", "trainee"],
permissions: ["roast:delete"],
requires: { "rest:GET /api/audit": { roles: ["owner"] } },
}
```
- **`read` says who the reviewer really is**, from the request, as `{ role }`
or `{ roles }` and optionally `permissions`; it returns `null` when nobody is
signed in. It is asked only of a preview and only after Maple's own identity
connector has resolved the reviewer, so it never runs on a production build.
- **`roles` and `permissions` are the vocabulary**, since no call shape lists
it. Every role and permission `requires` names is added.
- **The route serves `current` beside the rules**: `GET {base}/mock/identity`
answers `{ identity: { role, permissions, requires, server: { current } } }`.
When `read` returns `null`, the answer is still `200`, with
`current: null`: the vocabulary and `requires` stay useful, and a 401 would
put a failed request in the console of every signed-out page.
- **`displayedIdentity` shows the page the recipe's `as`.** The host calls it
where it seeds the client, with the identity it already read, and renders
with the result:
```ts
import { displayedIdentity } from "@maple-kit/mock/server";
const shown = displayedIdentity(request, real, { preview: isPreview, roles });
```
It returns `real` unchanged with no recipe, no `as`, a build that is not a
preview (the same switch as `mock.preview`), or a role `roles` does not
list. Otherwise it replaces `role` (and `roles`, when the identity has them)
and applies the permission grants and removals.
- **The client judges `requires` without a call.** The role the recipe names,
else the one `current` carries, decides which calls answer 403; the box lists
`current` as the real role.
- **Switching `as` reloads the page**, because the server renders it. The box
says so on the role and permission controls ("reloads the page") and applies
the switch at once, where a call-sourced identity waits for Apply.
**`displayedIdentity` is display only.** Render with what it returns and pass it
to the client, and never use it to authorise anything. Procedures, queries and
backend calls keep checking the real session: `as` changes what a reviewer
sees, never what they may do. The name says so on purpose. A guard written
against its result would let anyone who can set a cookie act as an owner.
**The page applies them.** `installMock({ route })` reads the rules once, and
only when the recipe has `as`; `installMock({ identity })` supplies them
instead.
- **The identity call's answer is told the recipe's identity**: the role
written at its path, and each permission named added to or taken from the
list, or set in the object. A role outside the rules' words is left as the
server said it, and a warning says so.
- **A call the shown identity may not make answers 403**, in the protocol's
own error shape, and is never sent. The identity is the recipe's where it
names a role or permission, and otherwise the reviewer's own, as the
identity call last answered. A need nothing can judge is let through: the
server decides. A call the recipe names keeps its named state.
- **A write is not blocked.** The server still acts as the reviewer, so a
write it allows really happens. Each one that reaches the server is logged
as a warning and listed on the handle's `writes`, for a surface to say.
- **Nothing changes without rules.** A recipe with `as` on a route that
declares none mocks only its calls, and a warning says so.
## Flags
A recipe's `flags` answer flags where the page evaluates them.
**OpenFeature**, the most portable, is `withMockFlags(provider)` from
`@maple-kit/mock/openfeature`:
```ts
OpenFeature.setProvider(withMockFlags(new VendorProvider(options)));
```
- **It answers the flags the recipe names and delegates the rest.** An
answered flag carries `variant: "maple-mock"` and reason `STATIC`. A value
of the wrong type answers OpenFeature's own `TYPE_MISMATCH`, as a provider
would, rather than a value the page did not ask for.
- **It is typed structurally**, so it wraps a web or a server provider and
imports neither SDK. `@openfeature/*` are not dependencies of the package.
- **A change to a named flag is held back.** A provider's configuration change
is passed on without the keys the recipe names, and not at all when nothing
is left, so the page never re-reads a flag it is being told something else
about.
- **Every evaluation is recorded** with the provider's real value in
`seenFlags()`, the page's one registry, for the box to list.
- **The recipe is the page's** by default, through the installed handle's
`current()`. A server passes the request's, from `requestRecipe` (see
"The recipe on the server").
**A vendor on the wire** is a flag source, `installMock({ flags: [...] })`,
for a page whose SDK is not behind OpenFeature. A source says which request
asks for flag values, reads the real ones for `seenFlags()`, and writes the
recipe's in; the interceptor forwards the request and answers with the
rewrite. Such a request is never recorded as one of the page's calls.
**LaunchDarkly** is `launchDarklyFlags({ baseUri?, streamUri? })` from
`@maple-kit/mock/launchdarkly`. Its browser SDK's format was read from the
source of `@launchdarkly/js-client-sdk` 4.10.3, and the tests run 4.10.2's
real client against a fake poll.
- **The 3.x SDK polls the same paths at another host.**
`launchdarkly-js-client-sdk` 3.x, which `launchdarkly-react-client-sdk`
3.x wraps, defaults `baseUrl` to `https://app.launchdarkly.com` where 4.x
uses `https://clientsdk.launchdarkly.com`. Read from
`launchdarkly-js-sdk-common` 5.8.0: the same `/sdk/evalx/…` GET and
REPORT, the same answer, over `XMLHttpRequest`, and the same stream host.
With no `baseUri`, both default hosts are claimed. A 3.x page that sets
`baseUrl` passes it as `baseUri`.
- **The poll is `GET /sdk/evalx/{env}/contexts/{context}`**, or a `REPORT`
to `…/context`, over `fetch`, answered by an object keyed by flag key. This
is FDv1, the SDK's default. FDv2, which runs only when `dataSystem` is
passed, is not read.
- **A named flag is answered with the largest safe version.** The SDK ignores
a `patch` or `delete` that is not newer than the version it holds, so no
real change replaces it. Its `variation` and `reason` are dropped, since
they describe the real evaluation.
- **The stream is the page's `EventSource`, wrapped** while a recipe has
flags. A `put` is rewritten; a `patch` or `delete` of a named flag is
dropped; a `ping` makes the SDK poll again, which is rewritten too.
- **The SDK caches what it is told** in `localStorage` and shows it before
its next poll answers, so the first load after Turn off shows the mocked
values until that poll does. `disableCache: true` on a preview avoids it.
- **Evaluation events still go to LaunchDarkly**, with the mocked value.
- **The poll offers every flag in the account; the page reads a few.** Add
`launchDarklyUsage()` where the host creates the client and the box lists,
and the plan is asked about, only the flags the page read on the route it
is on:
```ts
import { launchDarklyUsage } from "@maple-kit/mock/launchdarkly";
createClient(envId, context, { inspectors: [launchDarklyUsage()] });
```
It is a `flag-used` inspector, which the 3.x and 4.x SDKs call on every
`variation()`, and so on every `useFlags()` read. An SDK that prefers hooks
takes `launchDarklyUsageHook()` as `hooks: [...]` instead. The full poll is
still recorded, as the flags the page was _offered_: they supply each used
flag's real value, and they are the list when no inspector is installed
(nothing ever read), as before. Mocking is unchanged; a mocked flag the
page reads gets the mocked value.
- **Flags the server read** arrive in the `x-maple-flags` response header. The
interceptor records each key as used on the current route, with source
`"server"`, and the box tags it "server": a mocked value reaches the server
by the recipe cookie, so it applies after a reload. A server-only flag has no
known real value, so it offers an on/off choice. `docs/configuration.md`
(Flags the server read) has `withFlagUse` and `recordFlagUse`.
`@maple-kit/mock/testing` has `runFlagProviderContract`, the suite every
wrapped provider runs, and `memoryFlagProvider`. The package's own tests run
it against the memory provider and wrap OpenFeature's own in-memory providers
under the web and the server SDK.
## The inventory
The last real 2xx answer of every call, per route pattern, in memory and in
`sessionStorage` so it survives the reload that applies a mock. A mocked answer
is never recorded. It is bounded to twenty routes, fifty calls a route, and
keeps a body over 64 KB of JSON in memory only. A full or blocked storage
leaves it working in memory.
## The box
`` from `@maple-kit/ui/mock` is how a reviewer picks a state. It
lists the calls the page has made on this route, each with one button naming
its state, and Apply reloads into the choice. The button opens a menu of Real,
marked with a green dot as what the page does on its own, and the nine states.
Nine buttons per call made the box a wall of options that a reviewer scanned
past; the menu keeps each row to one line. It is a `popover="auto"` in the top
layer, since the box's body scrolls, and it closes when the body does. `m`
opens and closes the box. Inside `` the island header also has a Mock
button for it (drawn only where a transport is installed), and `m` answers only
while Maple is open: the card is up, or a pick is armed by the comment-modifier
tap. With the island collapsed `m` reaches the page, and opening the box while
a pick is armed cancels the pick. `` alone has no island, so `m`
always answers there (`shortcutWhen` on `createMockClient` is the seam). Escape closes it only when focus is inside Maple;
anywhere else the page keeps the key (`watchEscape` in `@maple-kit/core/client`).
Inside an open menu, Escape closes only the menu, which keeps its own Escape as
an open `popover="auto"`.
Inside `` it is `Maple.Mock`, the same part, in the overlay's own
shadow root.
**Search reaches what capture missed.** Each section's title row ("Calls on
/route", "Flags · N") carries a quiet "Search all" with a magnifying glass.
Clicking it turns the title into a focused field and the button into "Cancel",
in the same row, and only then loads: its code is a chunk of its own, and the
box has asked the route nothing before. While it is open, the section's body
shows what the search found, over everything and not the page's list: first
twenty, then "Show more". A result carries the state pick a listed row has, and
a muted hint where it comes from ("another route", "OpenAPI", "not read here");
choosing a state joins the draft and marks it "added". Cancel or Escape brings
the title and the page's list back, with every added row in it, marked "added".
Escape closes the search first and the box on the next press (`closeBox` in
`@maple-kit/mock/client`); focus returns to "Search all". Opening the Flags
search shows its results whether or not that section was folded.
- **APIs.** The tab's own calls across every route come first
(`Inventory.all()`, newest first), since they carry a real body, then the
calls `/mock/calls` lists. A call the page recorded is not listed twice: a
document's `/orders/{id}` is the `/orders/:id` a recorded call carries, and a
recipe names the second. A call only a document describes is drafted from its
schema, which `/mock/schema?key=` serves like any other. With no schemas (an
empty list, or a 404) only the tab's calls are searched, and the box says so
in one muted line.
- **Flags.** The known set, the flags the page was offered: a substring of the
key, case aside, in memory, with no request. A host with no flag source has
no "Search all" on the Flags row.
It is three layers, split as the overlay is:
- **`createMockClient()`** in `@maple-kit/mock/client` holds every rule: the
draft, the filter, what is in force on this route, Apply, Turn off and the
two copies. It touches no DOM until `start()`.
- **`useMock()`** in `@maple-kit/react/mock` is one subscription over it.
- **`MapleMock`** draws it, and touches no storage: persistence is the
client's.
**The box finds the transport rather than importing it.** `installMock` leaves
its handle on the page, and the client reads it there. `` therefore
carries the box's code on every page but the interceptor only where the host
installed it; with none installed the box draws nothing and does not bind `m`.
The Vite example's `verify` checks both halves: a production build with
`` mounted carries no interceptor, and its mock-only page carries none
of the island, the composer or the marks.
**On its own it has its own shadow host** and adopts `MOCK_CSS`: the tokens,
the base rules and the box's, which `scripts/size.js` keeps under 9 KB with
everything it reaches. Its scheme is the opposite of the page's, as the
overlay's is by default.
**A banner is on while a mock is**, naming the first call and counting the
rest. It has Edit and Turn off and no dismiss: a reviewer who forgets a mock is
on reads mocked data as real. It docks bottom-left, where a host's own controls
rarely are and the island is not (bottom-right); under 640 px it takes the width
and sits a row above the island, wrapping rather than covering the top bar.
**Where the route plans, the field is a sentence.** `installMock({ route })`
leaves a `plan` lookup on the handle beside `shape`, over the real `fetch`, and
the client asks it 500 ms after typing pauses, abandoning the request before.
It sends every call recorded on the route with a summary of the names in its
last answer, never a value. The list is not filtered while it plans.
What comes back passes the calm-UI gate, `readPlan` in `@maple-kit/core/mock`,
before anything is drawn:
| The plan | The box |
| ----------------------------------------- | ------------------------------------------------------- |
| confidence below 0.4 | nothing |
| `none` | "That doesn't name a state this page's data can be in." |
| a state, the runner-up more than 0.15 off | the calls go into that state |
| the runner-up within 0.15 | the likelier one, alone |
| no call concerned | nothing |
The reading goes straight into the draft, over what the draft held before the
sentence, and the sentence goes into the recipe's `request`. A chip to take it
was one more thing to read and click between the sentence and the result. Each
new reading replaces the last one over that same starting point; emptying the
field puts it back, and so does a sentence that names no state. A change by
hand makes the draft the reviewer's own, so emptying the field after it keeps
it. While a plan is on its way a sweep runs along the field's lower edge
(`aria-busy`), so nothing moves when it lands. A failure is swallowed, as the
assist tier's is, and a 404 turns the field back into a filter for the page's
life: every word must match a key, as before a plan existed. `plan: false`
keeps it a filter.
A call the recipe names that this route never recorded is listed after the
rest, since a mocked answer is never recorded.
**Flags and who the page is shown as are a second panel**, under the calls,
and the two scroll together between the field and the footer, so a short
window never squeezes the calls to a sliver. A reading scrolls the rows it set
into view. The panel is
a role picker and a Granted / Taken away pair per permission, from the
host's identity rules (`handle.identity()`, read once from
`/mock/identity`), and a toggle per flag the page evaluated (`seenFlags()`):
On / Off for a boolean, its variants where the source lists them, and its
real value, read-only, otherwise. Each row starts on the real value, marked
with a green dot: the role and permissions from the identity call's last real
answer (`MockClientState.realAs`), a flag from what the page evaluated.
Choosing the real option, or the chosen one again, drops the override. The
role is always shown; permissions and flags fold to `Permissions · 8` and
`Flags · 12`, since a host can declare dozens and a page evaluate hundreds, and
a folded list still shows every row the draft overrides. The words are the
host's and the page's; the box has no role list.
- **It is a lazy chunk.** `mock/layers.js` is loaded by a dynamic import only
when the page has identity rules, has evaluated a flag, or has a recipe with
either layer, so the box alone stays under 9 KB. `scripts/size.js` weighs a
dynamic import as its own entry (2.5 KB), and fails on one no budget names.
- **The banner says what `as` cannot do**, exactly: "Showing as barista. The
server still acts as you." It counts the flags set, and every write that
reached the server under `as`: "2 writes reached the server as you."
- **A recipe can name no call.** Flags or `as` alone apply, copy and link.
**Without a recipe store, the box shares a mock by copying**: Copy link writes
the page's URL with the recipe in `?maple-mock=`, and Copy recipe writes the
JSON. `RouteOptions.store` is optional for such a host, and the comment
endpoints answer 404 without one. With `RouteOptions.recipes` the link is short;
see the next section.
## Short links
A recipe in `?maple-mock=` grows with the calls it names. An "empty dashboard"
that empties 35 calls is about 2.1 KB of JSON and 2.8 KB as base64url, and a
host behind a WAF that blocks query strings over about 2 KB answers 403 before
the request reaches the app. Shortening the encoding only moves the limit, so
the recipe is stored on the route and the link carries an id:
`?maple-mock-id=`, 128 random bits. `RECIPE_ID_PARAM` names it, and
`linkRecipeId(url, id)` writes it, dropping the inline param as `linkRecipe`
drops the id.
- **Copy link and Apply store first.** The box `POST`s the recipe to
`/mock/recipes` and links with the id it gets back. A 404, which is what a
route with no store or with mock preview off answers, falls back to the
inline `maple-mock` param, so a host that configures nothing behaves as it
did.
- **A long link says so.** An inline link over 2,000 characters shows one muted
line, "This link may be too long for some servers."
- **Old links keep working.** `decodeRecipe` is unchanged, so every
`?maple-mock=` link already shared still opens.
- **Apply never fetches for the reviewer's own reload.** It saves to
`sessionStorage` before navigating, as it did.
**A store is a connector the host configures**, like `media` and `store`:
`RouteOptions.recipes`, a `RecipeStore` with `put(recipe, { expiresAt })`,
which returns the id, and `get(id)`. Core ships `r2Recipes()`, scoped by
`prefix` as `r2Media()` is, and `fileRecipes()` from `@maple-kit/core/local`,
which writes `.maple//mock/.json` for local runs.
Maple runs no shared service for recipes and defaults to none; there is no
GitHub store, because an attachment can be read by anyone who can read the
repository and cannot be deleted. [Privacy](privacy.md#mock-links) has the
rules.
- **A stored recipe expires.** `mock.recipes.ttlDays` sets the lifetime and
defaults to 30.
- **It keeps the least.** `version`, `calls`, `flags`, `as` and `route`.
`request`, the reviewer's free text and display-only, is dropped before
storing, and a mocked response is never stored.
- **The routes answer a signed-out reviewer too**, unlike the other `/mock/*`
routes: a link opens for whoever the preview opens for, and the 128-bit id
is the credential. 404 unless `mock.preview`; writes are rate-limited as
`/mock/plan` is, every signed-out reviewer sharing one count. `POST` answers
`{ id }`, 400 for a body that is not a recipe, and 413 over 16 KB without
echoing it. `GET /mock/recipes/{id}` answers the recipe, and 404 for an id
that is unknown, expired or malformed.
- **A comment keeps its inline recipe**, not an id. Comments outlive the TTL,
and a comment that pointed at an expired id would replay nothing.
**A page opened with an id holds its requests until the recipe arrives.** The
interceptor is installed before hydration and `readRecipe` is synchronous, so
with `maple-mock-id` it reports `{ kind: "pending", id }` and holds the page's
requests, never the route's own, until `GET /mock/recipes/{id}` settles. The
wait is capped by `RECIPE_LOAD_TIMEOUT_MS` (3 seconds); then requests pass
through and the box says the shared mock could not be loaded, since a mock that
silently shows real data looks like a working one. The id stays in the address
bar, so a reload retries. On success the recipe goes to `sessionStorage` and the
cookie, and the param is removed with `history.replaceState`, so the id is not
copied around by accident. An `EventSource` opened before the recipe arrives is
not held.
**The first server render of a fresh shared link is unmocked for server
reads.** A server cannot fetch the browser's store mid-request, so
`requestRecipe` still reads the cookie, which the page sets only after it has
fetched the recipe. The reload after that is right. The page's own calls are
not affected, only `flags`, `as` and `route` as a server evaluates them.
## A comment remembers the mock
A comment written while a mock is on stores the recipe as `context.mock`: every
call it named and the state it was put in, the route pattern, and the sentence
behind it. The recipe, not the mocked bodies: `error`, `forbidden`, `loading`
and `empty` replay identically, while `one` and `many` reshape whatever the
server answers at replay time. Bodies would cost the fence space and could put
a page's real data in a public pull-request comment.
- **The page says what is in force.** `installMock` leaves `current()` on its
handle, the recipe when it applies on the page's route, and core's
`activeRecipe()` reads it by `MOCK_HANDLE_KEY` without importing the
interceptor. `captureContext` records it with the viewport and the scheme.
- **The fence reads it through `parseRecipe`.** A recipe a build cannot read is
dropped from the comment rather than failing the ledger; the comment stays.
- **It is the first detail shed** when the fence is over budget, before the
quote's context: a replay is worth less than the words that locate a
comment. Nothing is shed where no comment carries a recipe.
- **The ledger row says `mocked`** beside the viewport, so a person reading the
pull request knows the page looked unlike the preview does now, and
`mocked as billing-manager, without invoice:void` under `as`.
- **An agent gets it back from `get_comment_context`** as `mock.recipe`, with
`mock.replay`, the comment's page with the recipe in `?maple-mock=`, and a
`mocked: trpc:roast.list empty (…)` line in its conditions that names the
flags and the identity too, and says the server acted as the reviewer. The link's
encoding, `encodeRecipe` and `linkRecipe`, is core's, beside the recipe.
- **`maple-action` reads the fence through core's `githubStore`**, keeps
fields it does not know, and the fence stays version 1: the field is
additive. A `maple-action` on an older core drops a version 2 recipe from
the comment and keeps the comment. It moves to the core that writes it in the same release.
## The recipe on the server
After the first load the recipe lives in `sessionStorage`, which a server never
sees, so a flag evaluated in a server render would show its real value. The
page keeps a `maple-mock` cookie for it, and `requestRecipe(request)` from
`@maple-kit/mock/server` reads it:
```ts
OpenFeature.setProvider(withMockFlags(provider, { recipe: () => requestRecipe(request) }));
```
A server whose provider is shared by every request, as OpenFeature's global
one is, keeps each request's recipe in an `AsyncLocalStorage` for the
duration of the evaluation. The Next example's `server/flags.ts` does, and
only on a preview:
```ts
const rendering = new AsyncLocalStorage();
OpenFeature.setProvider(withMockFlags(provider, { recipe: () => rendering.getStore() }));
rendering.run(requestRecipe(request), () => client.getBooleanValue("launch-week", false));
```
A server component has no URL, so the example builds `url` from its
`searchParams` and passes `headers()`. That makes the page dynamic on a
preview only; a production build never reads the request and stays static.
A flag evaluated only on the server is not listed in the box, whose list is
the page's own `seenFlags()`: a link or a copied recipe sets it.
- **Only a preview writes it.** The interceptor sets it on install and the box
on Apply, before the reload, so the reloaded page's server render reads it.
Turn off, a recipe that cannot be read and no mock at all clear it. A
production build has no interceptor and no box, so it never sets one.
- **It carries the layers a server evaluates**: `flags`, `as` and `route`,
never `calls` or `request`. Calls are answered in the page, and leaving them
out keeps the cookie small. A recipe with neither layer clears it, so the
cookie exists only while a server-side layer is on.
- **It is site-wide, `SameSite=Lax`, and `Secure` over HTTPS**, and lasts the
browser session. It is not `HttpOnly`, because the page writes it.
- **Over 4096 bytes it is not written.** Browsers drop a larger cookie without
a word, which would show real flag values in a mock that says otherwise, so
the interceptor clears it and warns, and the page's own layers still apply.
- **A `?maple-mock=` link in the request's own URL wins** over the cookie, as
it does in the page. A link or cookie that cannot be read is no recipe.
- **The route is not checked.** An API request made for a page has the API's
path, not the page's, so a server that cares compares `route` itself.
- **It is a subpath of its own**, not `./node`, because `./node` imports MSW,
an optional peer a server that only reads the cookie does not have.
- **It is opt-in.** Nothing reads the cookie unless the host calls
`requestRecipe` from its own server code.
## What is not done
- A vendor other than LaunchDarkly on the wire, and a flag's value typed in
the box: a string or number flag with no variants listed is read-only.
- `as` in `mockHandlers`, which takes no rules. On the server `requestRecipe`
gives the host the recipe's `as`, and applying it there is the host's code.
- A recorded error body shape. REST `error` answers `{ message }`, and tRPC
answers its default error shape.
- A delay for `loading`. It holds until the page reloads, and a held call in a
batch holds the whole batch.
- A second box on the same page. Each claims `m`, and the first to hear it
opens.
- A streamed procedure whose data is itself a promise or an async iterable.
Such a stream is not read, so it is neither recorded nor reshaped.