# @genesislcap/mock-server

A reusable, in-memory Genesis Framework WebSocket protocol server for client
automation and local dev — no JVM, no database, starts in milliseconds.

Written in TypeScript; **development and tests run straight from source**
under either runtime (`node --experimental-strip-types` on Node >=22.6, or
`bun run` — this package deliberately avoids Bun-only APIs like `Bun.serve`
in favor of `node:http` + the `ws` package, which both support identically).
All relative imports use explicit `.ts` extensions
(`import { X } from './foo.ts'`) because Node's type-stripping resolver
requires the literal extension match; `allowImportingTsExtensions` keeps
`tsc` happy with that.

**Consumers get compiled JavaScript.** The `build` script (`tsc -p
tsconfig.build.json`, with `rewriteRelativeImportExtensions` turning the
`.ts` specifiers into `.js`) emits `dist/`, and the package's
`main`/`types`/`exports` point there. This is not optional polish: Node
**refuses** to strip types for files inside a real (non-symlinked)
`node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so a
published package shipping only `.ts` is unusable with plain `node` outside
this monorepo. The `exports` map keeps a `"bun"` condition on `src/index.ts`
so Bun users still run the source directly. In-repo, `npm run bootstrap`
builds `dist` (via `lerna run build --no-private`); when hacking on the
engine against a consumer, keep `pnpm --filter @genesislcap/mock-server build:watch` running.

Type-check with `pnpm --filter @genesislcap/mock-server typecheck`
(`tsc --noEmit` over src + tests).

This package is the **protocol engine only**: message routing, `SOURCE_REF`
correlation, `CRITERIA_MATCH` evaluation, subscription/broadcast plumbing, and
auto-derived field metadata. It knows nothing about any specific project's
tables or business rules — that lives in per-project config, seeded via
`createMockServer(config)`. See
[`packages/showcase/client-app/mock-server`](../../showcase/client-app/mock-server)
for a complete example wired up against the showcase app.

## Usage

```ts
import { createMockServer, NackError, type MockServerConfig } from '@genesislcap/mock-server';

const config: MockServerConfig = {
  tables: {
    // events: generic EVENT_TRADE_MODIFY / _DELETE handlers
    // (see "Generic CRUD events"); EVENT_TRADE_INSERT is written out below.
    TRADE: { pkField: 'TRADE_ID', sequencePrefix: 'TR', rows: [...], events: ['MODIFY', 'DELETE'] },
  },
  views: {
    TRADE_VIEW: {
      base: 'TRADE',
      pkField: 'TRADE_ID',
      joins: [{ table: 'COUNTERPARTY', on: ['COUNTERPARTY_ID', 'COUNTERPARTY_ID'], select: { COUNTERPARTY_NAME: 'NAME' } }],
      derived: { TOTAL: (row) => row.PRICE + row.QUANTITY },
    },
  },
  queries: {
    ALL_TRADES: { source: 'TRADE_VIEW' },
  },
  requestReplies: {
    TRADE: { source: 'TRADE' },
  },
  eventHandlers: {
    EVENT_TRADE_INSERT: (details, { store, broadcastTableChange }) => {
      if (!store.getRow('COUNTERPARTY', details.COUNTERPARTY_ID)) {
        throw new NackError({ TEXT: `Counterparty '${details.COUNTERPARTY_ID}' does not exist`, FIELD: 'COUNTERPARTY_ID' });
      }
      const row = store.insertRow('TRADE', details);
      broadcastTableChange('TRADE', 'INSERT', row.TRADE_ID);
      return { generated: [{ TRADE_ID: row.TRADE_ID }] };
    },
  },
  auth: {
    users: [{ username: 'admin', password: 'admin', profile: ['USER_ADMIN'], permission: [] }],
  },
};

const server = createMockServer(config);
server.start(9064);
```

## Project-fidelity features

Beyond the basic table/view/query plumbing in the example above, the config
covers the server-side behaviors real projects lean on:

- **Computed request-replies** — for request servers that calculate rather
  than read (date calculations, holiday validate/roll, premium schedules,
  keyed composite lookups), give the `RequestReplyDef` a `resolver` instead
  of a `source`. It receives `{ request, message, user, store, config }` (the
  keyed `REQUEST` fields, the raw message, the requesting user, the live store,
  the server's config as it is then) and returns the `REPLY` rows verbatim;
  throw `NackError` for a
  protocol-correct error (a `MSG_NACK`; see
  [Request servers](#request-servers-req_)). Set `metadataRows` (a
  representative sample reply) so `META_REQUEST` can still derive
  `REPLY_FIELD`:

  ```ts
  requestReplies: {
    DATE_CALC: {
      resolver: ({ request }) => [computeDates(request.DATE, request.CALENDAR)],
      metadataRows: [{ INPUT_DATE: '20260101', RESULT_DATE: '20260105' }],
    },
  }
  ```

- **Query filters (where-clause equivalent)** — `QueryDef.filter` narrows a
  dataserver query the way the real query's filter clause does (keep template
  rows out of a main-entries query, CANCELLED out of a blotter). Applied to
  both the `DATA_LOGON` snapshot and live pushes, before any client
  `CRITERIA_MATCH`:

  ```ts
  queries: {
    ALL_TRADES: { source: 'TRADE_VIEW', filter: (row) => row.TRADE_STATUS !== 'CANCELLED' },
  }
  ```

- **Permissioning (auth maps / hideFields)** — `auth` on a query or
  request-reply scopes what each user sees. `rowFilter(row, user)` drops rows
  (snapshots, live pushes, and `REQ_` replies); `hideFields` (a list, or a
  function of the user) strips columns from rows **and** from `META_REQUEST`
  field metadata, so e.g. sales users never see the trader side of mirrored
  columns. `user` is `{ userName, profile, permission }` for the requesting
  connection:

  ```ts
  queries: {
    ALL_RFQS: {
      source: 'RFQ_VIEW',
      auth: {
        rowFilter: (row, user) => user.profile.includes('TRADER') || row.SIDE === 'SALES',
        hideFields: (user) => (user.profile.includes('TRADER') ? [] : ['TRADER_PX']),
      },
    },
  }
  ```

- **Join predicates and anti-joins** — beyond the GSF join model (see
  [Views: the join model](#views-the-join-model); a side-specific price join
  is a constant link there), a view join takes a `where(joinRow, viewRow,
  { tables })` predicate for matches no link can express, and `antiJoins`
  keeps only base rows with NO match (the `USER_WITHOUT_DETAIL` pattern):

  ```ts
  views: {
    RFQ_SALES_VIEW: {
      base: 'RFQ',
      joins: [{ table: 'RFQ_PRICE', on: ['RFQ_ID', 'RFQ_ID'], select: { SALES_PX: 'PX' },
                where: (price) => price.SIDE === 'SALES' }],
    },
    USER_WITHOUT_DETAIL: {
      base: 'USER',
      antiJoins: [{ table: 'USER_DETAIL', on: ['USER_NAME', 'USER_NAME'] }],
    },
  }
  ```

- **Composite primary keys** — `pkField` accepts an array
  (`pkField: ['RFQ_ID', 'SIDE']`). Rows are stored under the fields' values
  joined with `'|'` (so values must not contain `'|'`) — no synthetic key
  column, nothing extra in grids. Event handlers on composite tables pass
  `store.rowKey(table, row)` to `broadcastTableChange`; sequence generation
  still requires a single `pkField`.

## Views: the join model

A `ViewDef` is a GSF view (`view("TRADE_VIEW", TRADE) { joins { } fields { } }`)
and behaves as GSF 8.15.x views do (genesis-pal `view/join/*.kt`,
`ViewStructureReaderImpl.kt`; genesis-codegen `ViewRepoConfigGenerator.kt`):

```ts
views: {
  TRADE_VIEW: {
    base: 'TRADE',
    select: { TRADE_PRICE: 'PRICE' }, // TRADE.PRICE withAlias "TRADE_PRICE"
    joins: [
      // joining(COUNTERPARTY, backwardsJoin = true) {
      //   on(TRADE.COUNTERPARTY_ID to COUNTERPARTY { COUNTERPARTY_ID }) }
      {
        table: 'COUNTERPARTY',
        on: ['COUNTERPARTY_ID', 'COUNTERPARTY_ID'],
        backwardsJoin: true,
        select: { COUNTERPARTY_NAME: 'NAME' }, // COUNTERPARTY.NAME withAlias "COUNTERPARTY_NAME"
      },
      // joining(INSTRUMENT, JoinType.INNER) { on(...)
      //   .joining(EXCHANGE) { on(INSTRUMENT { EXCHANGE_ID } to EXCHANGE { EXCHANGE_ID }) } }
      { table: 'INSTRUMENT', type: 'INNER', on: ['INSTRUMENT_ID', 'INSTRUMENT_ID'] },
      {
        table: 'EXCHANGE',
        fromAlias: 'INSTRUMENT',
        on: ['EXCHANGE_ID', 'EXCHANGE_ID'],
        select: { EXCHANGE_CITY: 'CITY' },
      },
      // val BID = INSTRUMENT_PRICE withAlias "BID"
      // joining(BID) { on(TRADE.INSTRUMENT_ID to BID { INSTRUMENT_ID }).and(BID { SIDE } to "BID") }
      {
        table: 'INSTRUMENT_PRICE',
        alias: 'BID',
        on: [['INSTRUMENT_ID', 'INSTRUMENT_ID'], { to: 'SIDE', value: 'BID' }],
      },
    ],
    // derivedField("SPREAD", DOUBLE) { withInput(TRADE.PRICE, BID.PX) { price, bid -> ... } }
    derived: { SPREAD: (row, { tables }) => (tables.BID ? row.TRADE_PRICE - tables.BID.PX : null) },
    derivedTypes: { SPREAD: 'DOUBLE' },
  },
}
```

| GSF view DSL | `ViewDef` / `JoinDef` |
|---|---|
| `view("V", TRADE)` | `base: 'TRADE'`. `baseAlias` names it inside the view (default: the table name) |
| `joining(T)`, `joining(T, JoinType.INNER)` | `{ table: 'T' }`, `type: 'INNER'`. The default is `'OUTER'`, as in GSF |
| `val A = T withAlias "A"`, `joining(A)` | `alias: 'A'`, needed to join one table twice. Default: the table name |
| `on(TRADE.F to T { G })` | `on: ['F', 'G']`: the source row's field, then the joined table's |
| `.and(...)` | `on: [['F', 'G'], ['F2', 'G2']]`: every link must hold |
| `.and(OTHER { F } to T { G })`, a link from another table of the view | `{ fromAlias: 'OTHER', from: 'F', to: 'G' }` |
| `.and(T { G } to "VALUE")` | `{ to: 'G', value: 'VALUE' }`, compared with `===`, so use the column's own type |
| `.joining(U) { ... }` nested in T's join | a later join with `fromAlias: 'T'` |
| `backwardsJoin = true` | `backwardsJoin: true` (see the live changes below) |
| `T.F withAlias "X"`, `T.F withPrefix "P"`, `T.F` | the join's `select`: `{ X: 'F' }`, `{ P_F: 'F' }`, `{ F: 'F' }`. Base-table aliases go in `ViewDef.select` |
| `derivedField("D", DOUBLE) { withInput(A.F, B.G) { ... } }` | `derived: { D: (row, { tables }) => ... }` and `derivedTypes: { D: 'DOUBLE' }` |
| `fields { ... }` | `fields`: the output names, in order |

**Rows.**
- Joins run in order. A join with `fromAlias` reads its key fields from that table's row.
- A join without `fromAlias` reads them from the view row built so far: the base row's columns, overlaid by the selects of the joins before it. For a GSF view that is the base row's field. It also lets a join key on an earlier join's selected column, which is how chained joins were written before `fromAlias`.
- A null or missing key value matches nothing, because GSF builds no join request from it. Falsy values such as `0` and `''` do match.
- GSF only lets a dataserver read a view whose joins are one-to-one (their links cover a unique index), so a valid view has at most one match. When there are more, the last one in table order wins, or with a `where` the first that passes, as before this model. One-to-many fan-out is not modelled.
- An OUTER join that finds nothing keeps the row, with that join's columns unset (`null` on the wire for a declared view).
- An INNER join that finds nothing drops the row.
- A join whose source table found nothing finds nothing either. So an INNER join nested under an OUTER one also drops the row when the outer join misses.
- `derived(row, { tables })` and `where(joinRow, viewRow, { tables })`:
  - `row` / `viewRow` is the view row so far: the base columns plus the selects.
  - `tables` holds every table's source row by alias, so a derived field can read columns the view doesn't expose. An OUTER miss is `undefined`.
- Registration throws on a definition GSF would refuse to compile: a join with no link, an alias given twice, or a `fromAlias` (of a join or a link) that is neither the base nor an earlier join.
- A table joined twice, or the base table joined to itself, with no alias still works, as it did before aliases existed. The later join goes by no name, and the name keeps meaning the first table. Give it an `alias` to read it by name.

**Metadata.**
- Every column takes its type, enum values and size from its source table, through aliases and nested joins.
- A joined column keeps its own nullability only when its join is reached from the base through INNER joins alone. Any other joined column is nullable (GSF `transformOutputFields`).
- Derived fields take `derivedTypes`.

**Live changes** (fidelity `'gsf'`):
- **Base-table change.** The view row is rebuilt from the changed base row.
  - A MODIFY that makes an INNER join miss is pushed as a `DELETE`.
  - One that makes it match is pushed as an `INSERT`.
  - An INSERT that doesn't make it into the view pushes nothing.
  - This is GSF's mapping of view updates (genesis-db `Bulk.kt` `mapNullable`).
- **Joined-table change.** It is pushed only through joins with `backwardsJoin: true`, at any depth. GSF's view docs say that by default only root-table updates are published, and a joined table's need a backwards join (`ViewStructureReaderImpl.kt`; `JoinRepo.kt` listens to a joined table only for one). Without one, the client sees the joined table's new values when the view row's base row next changes.
- **What a backwards join pushes.** The view is compared as it was before the change and as it is after:
  - an `INSERT` for a row an INNER join now lets in;
  - a `DELETE` for a row it drops;
  - a `MODIFY` for a row joined to the changed record (before or after), or whose values changed;
  - nothing for the rest.
- **Each client gets what changed for it.** These are the candidates. Each subscription then compares a row with what its client was last sent, as for any live change ([Dataserver paging](#dataserver-paging)). A `MODIFY` carries only the columns that changed (a re-derived field and a joined column included). One that changes no column the client sees, such as a change to a column the view doesn't expose, sends nothing. A row the client doesn't hold arrives as an `INSERT`.
- **Anti-joins** are not a GSF construct, so a change to an anti-joined table is always pushed, as `INSERT`s and `DELETE`s.
- **A DELETE broadcast before its row is removed** counts the row as gone.
- **A view that joins its own base table** gets each changed base row once, from the base-table path.
- **Change rows through the store and broadcast them.** Use `insertRow` / `updateRow` / `deleteRow` and call `broadcastTableChange`. A stored row mutated in place leaves no earlier state to compare against.

**Behaviour change.** Up to 15.53, a change to any joined table pushed every held view row as a MODIFY. Now a join needs `backwardsJoin: true`, as on GSF, and only the rows it inserts, deletes or joins are pushed. `fidelity: 'legacy'` keeps the old behaviour and ignores `backwardsJoin`.

**Not modelled:**
- parameterised joins (`asParameter`) and lambda joins;
- `withFormat`;
- one-to-many views (GSF dataservers can't read them);
- a dataserver query's own `backwardsJoin = false` (GSF's default is `true`).

## Record identity, ROW_REF and sequences

Every stored row carries `RECORD_ID` and `TIMESTAMP`, as every Genesis table
row does (genesis-db `InsertOperation.kt`):

- Seeding and `insertRow` stamp `RECORD_ID === TIMESTAMP` from one counter per
  server that only goes up (`src/db/identity.ts`). A seed row's own
  `RECORD_ID` is kept when it is a safe integer or a string not used by an
  earlier seed row; an unsafe one (above 2^53, already rounded by
  `JSON.parse`) or a repeat is re-stamped, with one warning per table, and a
  missing `TIMESTAMP` becomes the `RECORD_ID`. An insert always gets fresh ones. `updateRow` moves `TIMESTAMP` on and
  keeps `RECORD_ID`, even if the patch names either field. A seed that brings
  the real server's ids as strings above 2^53 makes later stamps continue from
  the highest of them, as strings, so a new row is still the newest.
- **Deviation:** GSF's values are about 7.5e18 (epoch millis shifted left 22
  bits, plus a counter). That is above 2^53, so `JSON.parse` rounds
  neighbouring rows onto one number. The mock's stamps are epoch millis, or the
  last stamp plus one: exact, strictly increasing safe integers, in the same
  order, that still read as a write time.
- **Dataserver rows** never carry `RECORD_ID`/`TIMESTAMP` (the real dataserver
  identifies rows by `DETAILS.ROW_REF` only), unless the query's `fields` block
  names them. Field metadata (`META_REQUEST`, `JSON_SCHEMA_REQUEST`) leaves them out.
- **`REP_` rows** from a `source` include them, last, unless the request
  server's reply block (`RequestReplyDef.fields`) leaves them out — as GSF
  sends a table entity, criteria-only or not. Auth's `RIGHT` sends none only
  because its `reply { CODE DESCRIPTION }` block names neither (see
  [Request servers](#request-servers-req_)). A `resolver`'s rows are sent as
  returned.
- **`DETAILS.ROW_REF`** is the record's `RECORD_ID` as a string
  (`GenesisSetMaskingJsonSerializer`: `value.toString()`); a view row's is its
  base record's. Rows no longer carry a top-level `ROW_REF` — clients copy it
  from `DETAILS` themselves (`row.DETAILS[rowId] ?? row[rowId]`). Lookups stay
  by pk: handlers still pass the pk (or `store.rowKey`) to
  `broadcastTableChange`, and a pk-only row passed to `broadcast()` is matched
  to its record, a just-deleted one included. A view row is rebuilt from the
  changed base row, so a view's own `pkField` doesn't matter, and a MODIFY that
  takes the row out of the view (an INNER join or an anti-join) is pushed as a
  DELETE.
- **Live DELETE** is `ROW: [{DETAILS: {OPERATION: 'DELETE', ROW_REF}}]` and
  nothing else. The store remembers every record a key lost until a broadcast
  announces it (`broadcastTableChange`, or `broadcast()` with a DELETE), so a
  DELETE names each of them — a client gets the ones it holds — and a record
  replaced under the same key before one broadcast goes out as a DELETE of the
  old `ROW_REF` followed by the new INSERT. Deletes nobody broadcasts are kept
  for the next one (bounded: 16 per key, 10,000 keys per table).
- **Sequences:** `TableDef.sequenceFormat` is `'prefix'` by default
  (`sequencePrefix` + a counter padded to `sequenceWidth`: `TR001`), so
  existing seeds, tests and snapshots keep their ids. `'genesis'` mints
  `IdStrategy.java`'s format — 15-digit counter, sequence id, `LO`, `1`:
  `000000000000001TRLO1`. Seeds in the table's format advance the counter. A
  supplied value is kept; only a missing (`undefined`/`null`) one is generated.
- **Generated fields:** `insertRow` also fills the `TableDef.generated` fields
  a record leaves out: a `SEQUENCE` in the same format as the key's (its own
  `prefix`, else the table's `sequencePrefix`; with neither it stays shape
  only), an `AUTO_INCREMENT` from the highest seeded value plus one (1 with no
  seeds). Supplied values are kept and move the counters on.

`fidelity: 'legacy'` restores the pk-based `ROW_REF` (the pk value, or the
composite key), the top-level `ROW_REF`, pk-stub DELETE rows and `REP_` rows
without `RECORD_ID`/`TIMESTAMP`.

## NACK vocabulary

Service NACKs (`EVENT_NACK`, and the `MSG_NACK` a request server fails with) follow GSF's `GenesisError` family
(`src/protocol/errors.ts`, pinned against recorded frames in
`test/nack.test.ts`): every `ERROR` item has `@type`, a string `CODE`, `TEXT`
and a `"<code> <reason>"` `STATUS_CODE`, and the envelope carries `WARNING`:

| `@type` | Extra fields | Built by |
|---|---|---|
| `StandardError` | — | `new NackError('text')` (`REQUEST_FAILED`, 400: a failed GPAL `require()`), `NackError.nack(text)` (`INTERNAL_ERROR`, 500: GPAL `nack("text")`, which GSF wraps in an Exception), `NackError.standard(code, text?)` |
| `FieldError` | `FIELD`, `PATH` (`null` for a handler's own check) | `NackError.field(field, text, path?)`, or any input with `FIELD` (`VALIDATION_ERROR`) |
| `TableStandardError` | `TABLE`, `FIELD` | `NackError.duplicateKey(table, index, value)` (`DUPLICATE_KEY`, 500, `FIELD: '<INDEX> -> <value>'`), `NackError.recordNotFound(table, index?)` (404; the index for a modify, `''` for a delete) |
| `LoginError` | `DETAILS: null` | `{'@type': 'LoginError', ...}` |

`ErrorCode` mirrors GSF's `ErrorCode.kt` (names, default texts, statuses;
`statusCodeFor(code)`); unknown codes get `500 Internal Server Error`.
`new NackError([...])` reports several errors at once. Anything else a handler
throws is `StandardError INTERNAL_ERROR` with its message. A handler can
return `{ warnings: [...] }` for a NACK with an empty `ERROR` and those
warnings, which `IGNORE_WARNINGS: true` turns into a commit, or pass
`{ warnings }` to `NackError` to send them with its errors (see
[Commit semantics](#commit-semantics-validate-warnings-and-ignore_warnings)). A request server
that fails answers a service `MSG_NACK` carrying the same items:
`{WARNING, ERROR, MESSAGE_TYPE: 'MSG_NACK'}` (genesis-messages `MsgNack`). The
router's `MSG_NACK` keeps its own shape (no `@type`,
an integer `CODE`, `DETAILS {ERROR, STATUS_CODE}`). `fidelity: 'legacy'` sends
the old `{CODE, TEXT, FIELD?}` items (`VALIDATION_ERROR` by default) and no
`WARNING`: warnings go in `ERROR` there.

## Generic CRUD events (`TableDef.events`)

A table that only needs plain insert, modify and delete gets them without a
hand-written handler:

```ts
tables: {
  COUNTERPARTY: {
    pkField: 'COUNTERPARTY_ID',
    sequencePrefix: 'CP',
    fields: [/* FieldDef */],
    indexes: [{ name: 'COUNTERPARTY_BY_LEI', fields: ['LEI'], unique: true }],
    events: ['INSERT', 'MODIFY', 'DELETE'],
  },
}
```

Each verb registers `EVENT_<TABLE>_<VERB>` as the handler Genesis Create
generates for a table's `crud` event handlers (codegen-service
`JsonToKtsEventHandlersGenerator`): `eventHandler<Table>("<TABLE>_INSERT")`
and `_MODIFY` calling `entityDb.insert` / `modify`, and
`eventHandler<Table.ById>("<TABLE>_DELETE")` calling `entityDb.delete`, with no
`onValidate`. Those are what the showcase server runs — the recordings come
from them — and what Create's real tier deploys. The events are listed in
`RESOURCES_REQUEST` and described by `META_REQUEST` and `JSON_SCHEMA_REQUEST`
like any registered event (the table's DAO; a delete's `<Table>.ById`). Verbs a
table doesn't list are the router's 404.

| Event | Does | Replies |
|---|---|---|
| `INSERT` | Inserts DETAILS as the table's record: fields left out take their declared `default`, else `null`; sequence and auto-increment fields left out are generated | `EVENT_ACK` with `GENERATED: [{…}]`: every generated field, sorted by name, with its stored value — supplied or generated (`ack(listOf(mapOf("TRADE_ID" to insertedRow.record.tradeId)))`) — or `[]` for a table with no generated fields. A taken primary key or other unique index: `DUPLICATE_KEY` |
| `MODIFY` | Finds the record by primary key and writes DETAILS over it. DETAILS are the **whole record**: a field left out is reset to its default or `null`, as the deserialized DAO overwrites the stored row (recorded: a TRADE modify without `CREATED_AT` nulls it). Generated fields left out keep their values; `RECORD_ID` stays, `TIMESTAMP` moves on | `EVENT_ACK GENERATED: []`. No record: `RECORD_NOT_FOUND`, `FIELD` the key's index. A unique value another record holds: `DUPLICATE_KEY` |
| `DELETE` | Deletes by primary key | `EVENT_ACK GENERATED: []`. No record: `RECORD_NOT_FOUND`, `FIELD: ''` |

Index names follow the dictionary: a unique `indexes` entry over exactly the
key fields names the primary key, else it is `<TABLE>_BY_<fields>` without the
`<TABLE>_` prefix (`COUNTERPARTY_ID` → `COUNTERPARTY_BY_ID`).
`DUPLICATE_KEY`'s `FIELD` is `'<INDEX> -> <values>'`, values joined with
`' | '`. Every change is pushed live (`broadcastTableChange`), to queries on
the table and on views over it.

**`VALIDATE: true`** reaches every handler as `ctx.validate`, and nothing is
written (except under `fidelity: 'legacy'`). The generic events have no
`onValidate`, so they ack `GENERATED: []` straight away (recorded for
`COUNTERPARTY_INSERT`): no duplicate check, no
sequence value used, no lookup for a modify or delete. A hand-written handler
should run its checks and return before writing when `ctx.validate` is set
(recorded: `TRADE_INSERT`'s `onValidate` NACK still comes back); the engine
rolls back anything it writes anyway (see
[Commit semantics](#commit-semantics-validate-warnings-and-ignore_warnings)).

**`ctx.config`** is the server's whole config as the event finds it (the audit
tables it derives included), for a handler that depends on what else the
server holds: a table's unique indexes, the request servers it reads.

**An `eventHandlers` entry of the same name wins**, so a table can list all
three verbs and still write out the one with business rules.
`getEventHandler(name)` returns the generic handler when there is no entry, so
`extendEventHandler` can wrap it.

**Not genesis-eventhandler's typed handlers.** GSF also ships
`GenericInsertEventHandler`, `GenericModifyEventHandler` and
`GenericDeleteEventHandler` (`EventHandlerBuilder.registerCUDEvents`), which
Create doesn't use. They differ in three places: their insert's `GENERATED`
lists only the values that differ from DETAILS (`[{}]` when all were sent),
their modify and delete look the record up under `VALIDATE`, and their modify
makes the key fields mandatory in metadata.

### Inbound schema check

Before any handler runs, the real server checks the whole message against the
event's `INBOUND` JSON schema (`GenesisMessageDecoder`), and so does this
engine — for the generic events and hand-written ones alike — whenever the
DETAILS class is declared: a table event on a table with `fields`, or an
`eventSchemas` entry with `fields`. An inferred schema is a guess from the
rows and is never checked against. Every failure is one `FieldError
VALIDATION_ERROR` in a single `EVENT_NACK`, worded as GSF's validator (networknt
2.0.1) words it:

| Failure | `TEXT` | `FIELD` | `PATH` |
|---|---|---|---|
| Missing required field | `$.DETAILS: required property 'NAME' not found` | `NAME` | `$.DETAILS` |
| Unknown field (`additionalProperties: false`) | `$.DETAILS: property 'X' is not defined in the schema and the schema does not allow additional properties` | `DETAILS` | `$.DETAILS` |
| Wrong JSON type | `$.DETAILS.ENABLED: string found, boolean expected` | `ENABLED` | `$.DETAILS.ENABLED` |
| ENUM value not allowed | `$.DETAILS.TIER: does not have a value in the enumeration ["STANDARD", "PREMIUM", "TRIAL"]` | `TIER` | `$.DETAILS.TIER` |
| Nullable field invalid | `…: must be valid to one and only one schema, but 0 are valid`, then each branch's error | the field | its path |

Lengths (`maxLength`, in code points), ranges (`minimum`/`maximum`: an INT
must fit 32 bits) and BigDecimal's pattern are checked too, and so is the
envelope (`VALIDATE` must be a boolean). The order is the validator's: each
field's errors in field order, then missing fields, then unknown ones.
`test/schemaValidation.test.ts` pins all of it to the real validator's output
on the recorded schemas. A modify therefore needs every required field, as on
the real server. `fidelity: 'legacy'` checks nothing.

## Commit semantics: `VALIDATE`, warnings and `IGNORE_WARNINGS`

A handler stands for a GSF event handler's `onValidate` and `onCommit` at
once. The engine gives its outcome the semantics of GSF's
`TransactionalEventHandlerContainer` (genesis-pal-eventhandler, v8.15.29; the
typed `SyncValidatingEventHandler` is the same): `VALIDATE` runs `onValidate`
only; otherwise `onCommit` runs when `onValidate` acks, or when it NACKs with
warnings only (`error.isEmpty()`) and the message has `IGNORE_WARNINGS: true`;
any other NACK is the reply. So each handler runs in a transaction, and the
event commits only when it acks:

| The handler… | Without `IGNORE_WARNINGS` | With `IGNORE_WARNINGS: true` | Under `VALIDATE: true` |
|---|---|---|---|
| Returns `{ generated? }` | `EVENT_ACK`, committed | The same | `EVENT_ACK {GENERATED: []}`, rolled back |
| Returns `{ warnings, generated? }` | `EVENT_NACK {ERROR: [], WARNING}`, rolled back | `EVENT_ACK {GENERATED}` (no `WARNING`), committed | `EVENT_NACK {ERROR: [], WARNING}`, rolled back |
| Throws `NackError` (errors, warnings or both), or anything else | `EVENT_NACK`, rolled back | The same | The same |

*Rolled back* means every row the handler inserted, changed or deleted is
put back, and the live pushes it asked for through `ctx.broadcast` /
`ctx.broadcastTableChange` are dropped. They go out only once the event has
committed, right after its `EVENT_ACK`, the order the real server's
recordings show: a client can't count on its grid holding the change when
the ACK arrives. A push that fails then is logged, and the event stays
committed. Sequence and auto-increment values a rolled-back insert used stay
used, as a database sequence's do. A `VALIDATE` ack has `GENERATED: []`, as
the recorded ones do.

So a handler writes, then returns its warnings, and lets the engine decide.
A handler that returns its warnings *before* writing, as this README advised
before `IGNORE_WARNINGS` was modelled, now acks an ignored warning without
having written anything. `ctx.ignoreWarnings` is the message's flag (also
under `VALIDATE`, where warnings still NACK): read it to skip work, or before
throwing `NackError.warning(...)`, which always NACKs (the handler stopped
there, before its writes; GSF exceptions are always errors). Keep
`ctx.broadcastTableChange` for later (a timer, as the showcase's price ticker
does) and it pushes straight away once the event is over. The transaction
covers the store and the `ctx` broadcast functions only, not the server's
own `broadcast` / `broadcastTableChange` or side effects like timers. It
saves a table on the event's first write to it, a copy of its rows: cheap for
a mock's tables, noticeable only with very large ones and many events.

Where this differs from GSF: a GSF handler without `transactional = true`
keeps whatever its `onCommit` wrote before failing, and a transactional one
commits writes made before it *returns* `nack(...)`; here every NACK rolls
back. `IGNORE_WARNINGS` was not recorded (the showcase has no handler that
warns): `test/commitSemantics.test.ts` follows the Kotlin.
`fidelity: 'legacy'` keeps the old behaviour: no transaction (a handler's
writes stay whatever it replies), warnings always NACK (in `ERROR`), and
`ctx.ignoreWarnings` is always `false`.

State a handler keeps outside the store, which a rollback can't undo, changes
through `ctx.afterCommit(callback)`: the callback runs once the event has
committed, before its `EVENT_ACK` goes out, and never when the event NACKs or
only validates (at once under `fidelity: 'legacy'`). A failing one is logged.
The platform preset keeps its users' passwords this way.

A typed GSF handler whose reply isn't an `EventReply` (`eventHandler<In, Out>`,
e.g. auth's `CreateMfaSecretAck`) answers with its own message: return
`{ reply: { MESSAGE_TYPE: 'CREATE_MFA_SECRET_ACK', ... } }`, sent as it is in
place of the `EVENT_ACK` (under `VALIDATE` too, so return the validate reply
then), and throw `new NackError(errors, { messageType: '..._NACK' })` for its
NACK.

## Audit tables (`TableDef.audit`)

A table declared with `audit = details(id = …, sequence = "TA")` in the
dictionary gets a `<TABLE>_AUDIT` table from GSF (genesis-dictionary
`Audit.kt`, v8.15.29). `audit: true` (or `{ sequencePrefix: 'TA' }`; the
default sequence is `AU`) does the same here:

```ts
tables: {
  TRADE: { pkField: 'TRADE_ID', fields: [/* … */], events: ['INSERT', 'MODIFY', 'DELETE'],
           audit: { sequencePrefix: 'TA' } },
},
// What Genesis Create generates: requestReply(TRADE_AUDIT) { request(TRADE_AUDIT.BY_UNDERLYING_ID) }
requestReplies: { TRADE_AUDIT: { source: 'TRADE_AUDIT', requestFields: ['TRADE_ID'] } },
```

- **The table** has the audited table's fields, its own key `<TABLE>_AUDIT_ID`
  from the audit sequence, and `AUDIT_EVENT_TYPE`, `AUDIT_EVENT_DATETIME`,
  `AUDIT_EVENT_TEXT` and `AUDIT_EVENT_USER`. It is a table like any other: a
  query or request server can read it, and `setTable` makes a new, empty one
  for a new definition, as the table's rows are its new ones. GSF serves no
  resource for it by itself. A table declared under that name, at start or
  with `setTable`, is refused, as GSF's dictionary refuses two tables of one
  name. Like any sequence, the audit one is shared by every table on its
  prefix: a table registered, or set again, starts after the values the other
  tables on it have used, so no id repeats across them, while its own count
  restarts from its seed rows (under `fidelity: 'legacy'` only from those).
- **Every write an event handler makes** to the audited table adds a row: the
  record as written (as it was, for a delete), the event's `MESSAGE_TYPE`, the
  time, its `REASON` (else `null`) and the user, as GSF's event handlers write
  through `entityDb.audited(...)`. The rows commit and roll back with the
  event, and go out live to its queries after the `EVENT_ACK`. A write made
  outside an event (a seed row, `server.store` from a test) is not audited,
  nor is anything under `fidelity: 'legacy'`, which runs events without a
  transaction.

## Running several servers in parallel (and one-command dev stacks)

Every `createMockServer()` instance is fully isolated — its own store,
connections, sessions, and tokens — so parallel test workers/shards can each
run their own server in one process or many. The only shared resource is
ports; don't hand-assign them, use `listen()`:

```ts
const server = createMockServer(config);
const port = await server.listen(); // port 0 -> OS picks a free one
// hand `ws://localhost:${port}` to this worker's client
```

To boot a **mock server + client dev process as one command** (and N such
pairs side by side), use `launchStack` — it starts the server on an
ephemeral port, spawns the client command with `API_HOST` pointed at it, and
ties the lifetimes (client exit stops the server; one Ctrl+C tears down the
pair):

```ts
// mock-server/src/dev.ts
import { launchStack } from '@genesislcap/mock-server';
import { config } from './config.ts';

await launchStack({ config, clientCommand: 'pnpm run dev:vite' });
```

`clientEnv: (port) => ({ ... })` adds per-instance env on top of the default
`API_HOST` (e.g. to pin the client dev server's own port; vite's default
auto-increment already avoids collisions otherwise). Pass
`tieLifetimes: false` to manage shutdown yourself (e.g. inside a test
runner).

### Shutdown, and why there is a watchdog process

A client dev command is normally a launcher itself (`genx` -> `vite` ->
`esbuild`), so shutdown has to reach the whole tree, not just the pid that
was spawned. `launchStack` therefore starts the client as its own process
group leader and signals the **group**: SIGINT first, then SIGKILL after
`clientExitGraceMs` (default 5s) for anything that ignores it. `stop()`
resolves once the tree is actually down, not merely signalled. SIGINT,
SIGTERM, SIGHUP and process exit all take that same path.

None of that can run if the launcher itself is SIGKILLed — and then the
client is reparented to init and keeps holding its port, invisible on an
ephemeral-port setup. So `launchStack` also spawns a tiny detached watchdog
that polls for the launcher and takes the client's group down when it
disappears; the watchdog exits as soon as either side is gone, so it can
never outlive the pair it watches. Set `tieLifetimes: false` and you own all
of this yourself — no watchdog is started.

## Platform preset

A real GSF app runs beside the platform's processes: auth (users, profiles,
rights, the key-value store), and whichever of notify, reporting, file storage
and reconciliation it includes. Their resources are in its
`RESOURCES_REQUEST_ACK`, and its login ack carries the rights the auth seed
gives the user. `platformPreset()` serves that part, so an app's config only
has to describe the app:

```ts
import { createMockServer } from '@genesislcap/mock-server';
import { mergeConfigs, platformPreset } from '@genesislcap/mock-server/presets';

const server = createMockServer(
  mergeConfigs(
    platformPreset({
      // Default: admin/admin in every platform profile but NOTIFICATION_SERVICE.
      users: [{ username: 'admin', password: 'admin', profile: ['USER_ADMIN'] }],
      // The app's own data/*.csv rows for the platform tables.
      seed: { RIGHT: [{ CODE: 'TradeView', DESCRIPTION: 'View trades' }],
              PROFILE_RIGHT: [{ PROFILE_NAME: 'USER_ADMIN', RIGHT_CODE: 'TradeView' }] },
      // Default: all four. GSF and auth are always in.
      products: ['genesis-notify', 'file-server'],
    }),
    appConfig,
  ),
);
```

What it provides:

| Piece | From |
| --- | --- |
| Logins over the store (`auth.directory`): each user's `PROFILE` from `PROFILE_USER`, `PERMISSION` from `RIGHT_SUMMARY`, seeded with their profiles' `PROFILE_RIGHT` codes (`permission` overrides), so what auth's events change, the next login has; see [auth](#product-events) | the PBCs' seed CSVs, auth 8.15.5's bytecode |
| Tables `USER` (`STATUS` defaulting to `ENABLED`), `USER_ATTRIBUTES`, `PROFILE`, `PROFILE_USER`, `RIGHT`, `PROFILE_RIGHT`, `RIGHT_SUMMARY`, `KEY_VALUE`, `NOTIFY_ALERT`, as the dictionaries declare them, audits included | GSF 8.15.29, auth 8.15.5, genesis-notify 8.15.16 dictionaries |
| Dataservers `ALL_USERS` (`USER_VIEW`), `ALL_PROFILES`, `ALL_RIGHTS`, `ALL_PROFILE_RIGHTS`, `ALL_PROFILE_USERS`, `ALL_NOTIFY_ALERT_RECORDS` (the user's own alerts) | `auth-dataserver.kts`, `genesis-notify-rules-dataserver.kts` |
| Request servers `RIGHT`, `PROFILE`, `KEY_VALUE_LIST`, `KEY_VALUE_READ` (`VALUE` in base64, as a RAW column goes out) | `auth-reqrep.kts` |
| Events `EVENT_KEY_VALUE_PUT` / `_DELETE`, `EVENT_DISMISS_NOTIFY_ALERT` / `_DISMISS_ALL_NOTIFY_ALERT`, with the real handlers' NACK texts | `auth-key-value-eventhandler.kts`, `genesis-notify-eventhandler.kts` |
| `ALL_TOAST_NOTIFICATIONS`: the user's `NEW` alerts with `SHOW_TOAST` | `genesis-notify-rules-dataserver.kts` |
| Per included product, every query and request server that reads a table or view, over those tables (empty, but for notify's two seeded screen routes) and views, with their dictionary columns; see [Products](#products) | the products' dictionaries and scripts |
| `additionalResources`: the platform resources a real 8.15.29 server lists (the recorded 207 minus the showcase app's own: 177 with every product) for GSF, auth and the included products | `test/conformance/recordings/showcase-real/resources-request.json` |

A listed resource with no backing (see [the gaps](#what-isnt-backed)) gets the
router's 404, as an unknown one does, until a config serves it.

### Products

`products` names the platform products the app includes beside GSF and auth,
which are always in, by GSF product name: `'genesis-notify'`, `'reporting'`,
`'file-server'` (Create's Document Management) and `'reconciliation'`
(`PlatformProduct`; `PLATFORM_PRODUCTS` lists them). Default: all four, which is
what the showcase config and the conformance suite run. The preset lists and
serves only GSF's, auth's and those products' resources; another product's get
the router's 404, as on a real server without it. A product's screen may read
another's: reporting's reads file-server's `FILE_STORAGE_TEMPLATES`, and
reconciliation's reads `ALL_FILE_STORAGES`. `seed.NOTIFY_ALERT` needs
`'genesis-notify'`; an unknown name throws.

**Attribution.** `src/presets/platform/resources.json` gives each resource the
product whose script registers it (`PRODUCT`): GSF `genesis` 22, `auth` 44,
`genesis-notify` 53, `reporting` 27, `file-server` 7, `reconciliation` 24. A
query, request server or event handler is found in the scripts by the name GSF
registers it under: `query(TABLE)` is `ALL_<TABLE>S`, `requestReply(TABLE)` is
`TABLE`, a typed one with no name takes its class's (`requestReply<Unit,
ValidTimeZone>` is `VALID_TIME_ZONE`, reporting's), an event handler is
`EVENT_<name>`. GSF registers 20 in its own code (status dataservers; cron,
dynamic-rule and approval events; the default `EVENT_INSERT` / `_MODIFY` /
`_DELETE`), cited by file in `generate.ts`. Generation fails if any recorded
resource is left unattributed or claimed twice.

**Backing.** For each query and request server in a product's scripts that
reads a table or view, `src/presets/platform/backing.json` holds the engine's
definition, the script and line it comes from, and the tables it reads:

- Tables and views come from GSF's own compilation of the product's
  dictionaries (its `<product>-dictionary-cache` jar), not from re-parsing
  Kotlin. A column keeps its type, ENUM values (an ALIASED_ENUM's labels, as GSF
  sends, reads and describes them: `GENERATED_REPORT.STATUS` is `'Generated'`),
  default (an inline enum's first value when it names none), size (but GSF's default 64 and `dbMaxSize`) and
  not-null (key, index, `not null` and dictionary not-null fields, as
  `VerifiedTable.getNotNullFields` makes them). A view keeps its joins (type,
  `backwardsJoin`), renamed columns and derived fields' types.
- A query's `fields { }` and `indices { }`; a request server's `request { }`,
  `request(TABLE.BY_...)` (that index's fields) and `reply { }`. With no request
  block it is keyed by its source's first index, as
  `RequestReplyServer.getRequestFields` does (a view's: its base table's), so
  `NOTIFY_DESTINATIONS` asks for `ID`. A view has its base table's key even when
  it doesn't list it, as GSF adds it (the dictionary's `PRIMARY-KEY` imports).
  A typed `requestReply<Unit, SomeView>` serves that view's rows with no request
  (`GENERATED_REPORTS`, whose Kotlin reply reads them newest first).
- Every table of an included product is there, read or not: its event handlers write them.
  An audited one (`audit = details(..., sequence = "XA")`) carries `audit` with that sequence,
  so the engine derives its `<TABLE>_AUDIT` table (see [Audit tables](#audit-tables-tabledefaudit)),
  which a script may read: notify's `NOTIFY_ALERT` request server does.
- Rows: none, but a product's own seed data (notify's `NOTIFY_ROUTE.csv` and
  `SCREEN_NOTIFY_ROUTE_EXT.csv`), the columns a row leaves out taking their
  defaults as GSF's data load does.
- The pieces `platform.ts` writes out (`ALL_PROFILES`, the key-value store,
  the alerts) replace generated ones of the same name.

What each product's screens read, and so what is now served (zero rows unless
said):

| Product | Dataservers | Request servers |
| --- | --- | --- |
| auth | `ALL_USERS`, `ALL_PROFILES` (rows: the users, profiles); also `ALL_USER_RIGHTS`, `ALL_USER_AUDITS`, `ALL_USER_LOGIN_AUDITS`, `ALL_CRON_RULES` (reporting's schedules), `ALL_APPROVAL_ALERTS`, `ALL_APPROVAL_ALERTS_AUDITS` | `USER`, `RIGHT`, `PROFILE_RIGHT`, `PROFILE_USER` (rows: the users, rights); also `USER_DETAILS`, `PROFILE_USERS`, `USER_PROFILES`, `USER_LOGIN_AUDIT`, `USER_ATTRIBUTES_AUDIT`, `TAG` |
| genesis-notify | `ALL_DYNAMIC_NOTIFY_RULES`, `ALL_RULE_TEMPLATES`, `ALL_SCREEN_ROUTES` (2 rows), `ALL_EMAIL_DISTRIBUTION_ROUTES`, `ALL_EMAIL_USER_ROUTES`, `ALL_LOG_ROUTES`, `ALL_MS_TEAMS_ROUTES`, `ALL_SLACK_ROUTES`, `ALL_NOTIFY_ALERT_RECORDS`; also `ALL_NOTIFY_RECORDS`, `ALL_TOAST_NOTIFICATIONS` | `NOTIFICATION_RULE`, `NOTIFICATION_RULE_TEMPLATE`, `NOTIFY_ALERT` (the user's alerts' audit) |
| reporting | `ALL_REPORT_CONFIGS`, `ALL_SAVED_REPORTS` | `GENERATED_REPORTS`, `NOTIFY_DESTINATIONS`; also `SAVED_REPORTS`, `REPORT_CONFIGS`, `SFTP_DESTINATIONS` |
| file-server | `ALL_FILE_STORAGES` | `FILE_STORAGE`, `FILE_STORAGE_TEMPLATES`, `FILE_STORAGE_ASSETS` |
| reconciliation | `ALL_RECONCILIATION_VIEWS`, `ALL_RECONCILIATION_RECORD_RESULTS_VIEW`, `ALL_RECONCILIATION_RULE_RESULTS`, `RECONCILIATION_SUMMARY` | `RECONCILIATION_CONFIG`, `RECONCILIATION_RULE`, `DATASOURCE_CONFIG`, `DATA_DICTIONARY`, `DATA_DICTIONARY_FIELD`, `RECONCILIATION_RULE_RESULT` |

#### Product events

The preset runs the products' event handlers it models as their scripts do (one
module per product, `src/presets/<product>.ts`), DTO schemas included, so the
products' forms build and their saves land. What happens outside the database
(email, Teams, Slack, report files, reconciliation matching) isn't modelled: the
event validates, writes its rows (audited, as every event write is) and acks.

- **Auth** (`auth.ts`): its 15 user, profile, password and MFA events as
  `auth-user-eventhandler.kts`, `auth-profile-eventhandler.kts`,
  `auth-password-eventhandler.kts` and `auth-mfa-eventhandler.kts` run them,
  with their NACKs (a script's `StandardError("CODE", ...)` is 500 whatever its
  code). A user, a profile and their links keep `RIGHT_SUMMARY` inside the event,
  as auth's consolidation does, quirks included: `EVENT_USER_PROFILE_AMEND`
  keeps a removed profile's rights, and `EVENT_DELETE_PROFILE` leaves its users'
  (it re-reads a profile's users after deleting them). A new user starts
  `PASSWORD_EXPIRED`, auth's default: their first login answers
  `PASSWORD_EXPIRED`, and foundation-auth's change of password
  (`POST gwf/event-change-user-password`, logged in or out, as GSF's router
  takes it) enables them. An amend writes the user whole, as the script's
  entity modify does: a column it leaves out is cleared. Logins read the same
  tables and write what auth's do: the checks in its order (an unknown or
  `DISABLED` user and a wrong password alike `INCORRECT_CREDENTIALS`, an
  expired password `PASSWORD_EXPIRED` whatever the password, another status but
  `ENABLED` `LOCKED_ACCOUNT`, no `PASSWORD` `NO_COMPATIBLE_PROVIDERS`), the
  lockout (three failures lock a name out for five minutes, `MAX_TRIES_REACHED`,
  in `USER_LOGIN_ATTEMPT`), a `USER_LOGIN_AUDIT` row per login, failed login and
  logout (`IP_ADDRESS` as the router gives it: the `HOSTNAME` header, else
  `/<address>`), and `USER`'s `ONLINE` and `LAST_LOGIN`. Passwords are kept
  hashed beside the store, never in `USER`, and change when their event
  commits. `USER_PROFILES` and `USER_ATTRIBUTES_AUDIT` answer as the reply
  blocks say. `ALL_APPROVAL_ALERTS` stays empty (the engine doesn't hold
  events for approval), as do `TAG` and, until reporting schedules one,
  `ALL_CRON_RULES`. MFA's events answer with their own replies, a secret
  waiting in memory for its confirmation (any code confirms it); no login asks
  for one.
- **genesis-notify's routes** (`notify.ts`): the 18 create, update and delete
  events of the six route families (screen, email distribution, email user,
  MS Teams, Slack, log) write `NOTIFY_ROUTE` and the family's ext table as
  `genesis-notify-eventhandler.kts` does, with its NACKs (`NOTIFY_ROUTE <id> not
  found`, a missing ext row's `RECORD_NOT_FOUND`). An email distribution route's
  `Set<String>` fields are arrays in the schema, stored comma-joined; the routes
  screen sends the stored string back on an update, which GSF's schema check
  refuses too.
- **genesis-notify's rules and templates** (`notifyRules.ts`): the 10 rule and
  template events (create, update, delete, subscribe, unsubscribe, enable,
  disable) write `DYNAMIC_RULE`, the template's params, its subscribers' links
  and their alert sounds as `genesis-notify-rules-eventhandler.kts` does, with
  its owner-or-`NotificationAdminAction` checks and alert-sound limits.
  `RULE_EXPRESSION` and `RESULT_EXPRESSION` are stored as GSF's
  `EvaluatorExpressionWriter` writes them, and a subscription fills the
  template's placeholders as Mustache does (each value HTML-escaped, then
  `&quot;` back to `"`). `NOTIFICATION_RULE_DETAILS` and
  `NOTIFICATION_RULE_TEMPLATE_DETAILS` answer with the expression model the
  event sent, where GSF parses the stored string back; a rule the mock didn't
  write answers its stored string as the raw expression. `NOTIFY_ALERT_SOUND`
  plays a sound to whoever the script lets hear it. The rule and template reads
  (`ALL_DYNAMIC_NOTIFY_RULES`, `ALL_RULE_TEMPLATES`, `NOTIFICATION_RULE`,
  `NOTIFICATION_RULE_TEMPLATE`) apply their `where` / `filter`, and
  `NOTIFICATION_RULE` its user filter. Rules never fire: GSF's rules engine,
  which raises `EVENT_NOTIFY_INSERT` when a matching record changes, isn't
  modelled. The expressions' schema is any object, as their class tree can't
  be described here.
- **genesis-notify's notifications** (`notifyDispatch.ts`): `NOTIFY_INSERT`
  (with its sender check), `NOTIFY_SCREEN_DIRECT` and `NOTIFY_EMAIL_DIRECT`
  write `NOTIFY` and its attachments as the scripts do. Then, after the ack and
  outside the event's transaction, the dispatcher routes the row as GSF's
  does: by topic (an enabled route whose `TOPIC_MATCH` is `*` or a prefix of
  the topic), direct, or basic (`MY_SCREEN`, `ALL_SCREENS`), the route's users
  resolved by `ENTITY_ID_TYPE` and filtered by `EXCLUDE_SENDER` and
  `RIGHT_CODE`. A screen gateway gives each user one unaudited `NOTIFY_ALERT`
  (the inbox and the toasts), with the routes' expiry, after which the alert
  turns `EXPIRED`, audited as `ScreenAlertExpiryManager`'s. A route naming an
  auth cache reaches nobody, as the mock has none; email, Teams, Slack and log
  deliveries aren't made. The gateways are the `notifyGateways` option
  (default the product's: screen `Screen`, email `Email`), which
  `CONFIGURED_NOTIFY_ROUTES` lists. `NOTIFY_ROUTE_TOPICS` lists the routes'
  topics and the broadcast ones; `NOTIFY_SYSTEM_ENTITY` the app's tables
  (audit tables included) and views, or the `notifyEntities` option's (the
  sysdef's `NOTIFY_ENTITY_LIST`).
- **Reporting** (`reporting.ts`): its 15 events write report configurations
  (with their datasources, parameters, destination maps and schedules, a
  schedule a `CRON_RULE` firing `EVENT_GENERATE_REPORT`, which the mock has no
  scheduler to run), destinations and saved reports as
  `reporting-eventhandler.kts` does, with its NACKs; the fields the events take
  from the DAO classes they unwrap are described and checked with the DAO's
  annotations (`FILE_NAME` at most 256 characters). A schedule is refused as
  the product refuses it, with log4j's `CronExpression` and Java's `ZoneId.of`
  messages, ported (`cronExpression.ts`, `javaTime.ts`). `EVENT_GENERATE_REPORT`
  writes the run's `GENERATED_REPORT` row as `ReportingManager` does: its file
  name with `$(timestamp:<Joda pattern>)` (in the `reportingTimeZone` option's
  zone, the sysdef's `REPORTING_DEFAULT_TIMEZONE`, else, or when it can't be
  resolved, the host's) and `$(parameter:<key>)` filled in (a bare
  `$(parameter)` fails the run, as the product's `!!` does), a file id, and a
  status: `Generated`, or
  `Delivered` once dispatched to its destinations, a notify destination
  raising the "New Report Generated" notification genesis-notify routes, an
  SFTP one failing as the product's `TODO()` does. A run that fails past its
  first checks NACKs and keeps its row `Failed`, as the non-transactional
  handler does. No file is made: the datasources aren't run. The request
  servers answer as the product's Kotlin does: `REPORT_CONFIG_DETAILS` (the
  stored transformer configuration read back whole), `REPORT_PARAMETER_DETAILS`
  (a JSON schema from the datasources' own), `ALL_REPORT_DATASOURCES` (every
  request server, or the `reportingDatasources` option's, the sysdef's
  `REPORTING_DATASOURCE_LIST`), `VALID_TIME_ZONE` (JDK 17.0.12's 603 zone
  ids), `REPORTING_DATA_PIPELINES` (none), and `GENERATED_REPORTS` every run,
  newest first.
- **Reconciliation** (`reconciliation.ts`): its 9 events write data
  dictionaries and configurations (rules, datasources) as
  `reconciliation-eventhandler.kts` does, with `ReconStrategyUtils`' and
  `FieldToleranceUtils`' checks (a strategy's datasource count, a tolerance
  type and value a field type allows) and its other NACKs. A run is the
  product's lifecycle without its matching: checked as `ReconciliationManager`
  checks it, started `IN_PROGRESS` (its input parameters pretty-printed as
  Jackson does), then `COMPLETED` after the `reconciliationRunMs` option (default
  1000 ms) with no record results, as the mock reads no data to match. A
  datasource that can't start fails the run as the product does: the run's
  row stays `FAILED` though the event NACKs (the handler isn't
  transactional); a FILE one needs its input parameters and a `.csv` file
  file-server has, a DATABASE one isn't implemented, a PIPELINE one with no name
  fails once running. A live run can be cancelled; a comment is kept per
  record result. The request servers pair the datasources as the strategy says
  (`RECONCILIATION_DATA_SOURCE_PAIRS`), list the live configurations, a field
  type's tolerance types, a record pair's values, and the
  `reconciliationPipelines` option's pipelines. The configuration editor
  resends its rules and datasources with keys their classes don't have
  (`RECORD_ID`, `IS_EDITING`): GSF's schema leaves nested classes open and its
  decoder drops those keys, and so does the mock.
- **File-server** (`fileServer.ts`): the router's `file-server/` endpoints
  (`upload`, `replace`, `download`, `all-files`, `delete`) as
  `file-server-web-handler.kts` serves them, for a logged-in user (401
  otherwise), with the content validator's refusals (an empty file, an
  extension off `ALLOWED_FILE_EXTENSIONS`, case-sensitive, a long name or a
  special character; not its content sniffing). Files are saved one after
  another, so those before a refused one are kept. An upload is a
  `FILE_STORAGE` row with a uuid-strategy id (`<uuid>FFLO1`), kept under
  `SUB_DIR` with `(1)`, `(2)` before the extension when the name's taken; its
  bytes are kept in memory for `download`, so a body over 100 MiB is refused
  as the router refuses one over its 1 GiB `maxFileSize` (400, `File exceeds
  max size of ...`). A download's name above U+00FF goes out as `?`, as Netty
  writes the header. `EVENT_FILE_STORAGE_DELETE`,
  `EVENT_LINK_TEMPLATE_ASSETS` and `ASSETS_FOR_TEMPLATE` work over them. A
  report run doesn't make a file, so its `REPORT_FILE_STORAGE_ID` downloads
  nothing.

- **GSF** (`gsf.ts`): `SYSTEM_ENTITY` and `SYSTEM_ENTITY_FIELDS` as
  `system-info-reqrep.kts` answers them, typed request servers over the
  server's dictionary. The entities are the tables, each audit table right
  after its own, then the views (every product's, not the ones the preset
  declares only to serve a renamed column), in the config's order where GSF
  lists the tables by product in its dictionary files' HashMap order. A
  pattern matches a name whole (`*` any run for `SYSTEM_ENTITY`; as given, the
  first match, for `_FIELDS`), and one Java refuses fails with its message
  (`Dangling meta character '*' near index 0`). Java's spellings JavaScript
  lacks (`\Q...\E`, `\A`, `\z`, a leading `(?i)`) are translated; an inline
  flag later in the pattern fails with JavaScript's message. Matching stops
  after a second (`GENERIC_ERROR`): a pattern that backtracks that long ties
  up one request thread on GSF, but would stall every socket here. A field is described as GSF's
  generated config classes give it: `MAX_SIZE` and `FILE_NAME` always null,
  an ALIASED_ENUM by its keys, an ENUM defaulting to its first value, not
  nullable when it's in any index, generated or defaulted, then `RECORD_ID`
  and `TIMESTAMP`; a view's fields by their names in it, a joined or derived
  one nullable, none with a default. A platform table is described as its
  product declares it (`USER` with `PASSWORD`, which the preset keeps out of
  its own). `ALL_PROCESSES_STATUS` and `ALL_SYSTEM_STATUS` are
  `StatusDataserver`'s, on GSF's older engine, their fields in its order: no
  rows, as only GSF's Resource Daemon fills them and Genesis Start and Create's
  preview don't run it. `NOTIFY_SYSTEM_ENTITY` lists the same entities.
- **Typed request servers.** The products' 17 `requestReply<In, Out>` servers
  (`VALID_TIME_ZONE`, `REPORT_CONFIG_DETAILS`, `NOTIFICATION_RULE_DETAILS`,
  `RECONCILIATION_DATA_SOURCE_PAIRS`, ...) answer META as GSF 8.15.29 builds it
  from the pinned jars (`scripts/platform-preset/seeds/typed-request-servers.json`,
  sent as it is: their classes' sealed subtypes and DAO annotations), and their
  replies carry `PARAMETRIC_TYPE` and stop at `MAX_ROWS`.

#### What isn't backed

Listed, and answered with the router's 404: **GSF's own events**, cron rules,
dynamic rules, pending approvals and the default `EVENT_INSERT` / `_MODIFY` /
`_DELETE`.

Not modelled in what is served: `where` / `filter` clauses but the rule and
template reads' (their tables start empty, and generation refuses one over a
seeded table), permissioning (permission codes, auth maps, as elsewhere in
the preset: auth's `MFA_ENABLE` and `MFA_DISABLE`, which no platform profile
holds, don't refuse anyone here), and the audit rows GSF's data load writes
for seeded rows. Rights are checked only where a product's own code checks
them (`CHANGE_PWD`, `EXPIRE_PWD`, genesis-notify's sender rights): any user
can still change a profile's users, so these checks guard nothing here.

Derived view fields are computed as the products' view dictionaries define
them: `DYNAMIC_RULE_VIEW.DATETIME`, `GENERATED_REPORT_VIEW.LAST_UPDATED_AT` and
`FILE_NAME`, `RECONCILIATION_VIEW.DATETIME` (the run's start, else its
timestamp) and `RECONCILIATION_RECORD_RESULT_VIEW.DATETIME`. GSF's
`TIMESTAMP shr 22` is the mock's TIMESTAMP, which is already epoch millis.

**Composing.** `mergeConfigs(...configs)` merges later configs over earlier
ones: tables, views, queries, request servers, event handlers and event
schemas by name (a later one replaces the earlier whole), `auth.users` by
username, `additionalResources` without repeats, `httpRoutes` (later first) and
`onMessage` hooks (in order) both kept, anything else the later config's. So an
app overrides a platform piece by naming it, e.g. its own
`eventHandlers.EVENT_KEY_VALUE_PUT`. Logins find the preset's users in its
`USER` table (`auth.directory`), so a user another config adds to
`auth.users` can't log in: add them with `platformPreset({ users })`.

**Resources.** `config.additionalResources` is listed after the resources the
engine derives from the config, skipping any already there (same name and
type). `config.resources` still replaces the whole list.

**Who a request runs as.** The per-user resources (the KV store, alerts) use
the connection's login. A client that logs in over HTTP sends no session token
on its WebSocket, so for a connection that never logged in they use
`sessionUser` (default: the first user).

**Seeds.** Rows are strings or values; declared columns convert them as GSF's
loader does. `parseSeedCsv(text)` reads a GSF seed CSV (empty cells left out),
so an app can pass its own `data/*.csv` (the showcase mock does). A seed row
with the key of a platform row replaces it, as the real data load upserts.

**Regenerating the data.** `src/presets/platform/*.json` is generated:
`npm run preset:generate` reads the recordings, the showcase server's scripts
and seeds, and `scripts/platform-preset/seeds/<product>@<version>/`, at the
versions the showcase server pins (GSF itself is `genesis@8.15.29`):

- `PROFILE.csv`, `PROFILE_RIGHT.csv`, `RIGHT.csv` and any other seed data
  (notify's routes), verbatim from the product's repository or config jar.
- `dictionary/<product>-{fields,tables,view}-dictionary.kts.json`, verbatim
  (formatted) from `<product>-dictionary-cache-<version>.jar`'s
  `META-INF/global/genesis/genesis-script-file-cache/`.
- `scripts/*.kts`: the product's dataserver, request-server and event-handler
  scripts, verbatim from its config jar (GSF's `system-info-reqrep.kts` from
  genesis-server's `genesis-console`).
- `typed-request-servers.json` (beside the product directories): the typed
  request servers' META DETAILS, built by running GSF's own
  `CustomReqRep` metadata, `MetadataWrapper` and `FeatureAck` code against the
  8.15.29 and pinned product jars (checked against a recorded 8.15.29 event
  META, byte for byte).

It then formats the output with oxfmt. Re-record, re-copy, rerun.

## Extension points

For everything the protocol handlers don't cover, three hooks avoid ever
having to monkey-patch the engine:

- **`config.httpRoutes`** — extra HTTP endpoints on the engine's shared
  `http.Server` (e.g. a project's `GET pbi/configs`). Tried before the
  built-in login/SSO handlers, so they can also override those. A string
  `path` matches `pathname.endsWith(path)` (tolerating any gateway prefix);
  a RegExp is tested against the full pathname. The handler's `ctx` has the
  store, the live pushes, the session's `userName` (none without one) and
  `commitEvent(message, userName)`, which runs an event as that user through
  the socket's commit path and gives back its reply, as GSF's router answers
  an event POSTed over HTTP (the platform preset's change of password):

  ```ts
  httpRoutes: [{
    method: 'GET',
    path: 'pbi/configs',
    handler: (req, res) => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ CONFIGS: [...] }));
    },
  }]
  ```

- **`config.onMessage`** — called for every parsed WS message before routing.
  Return a message to continue routing with it (possibly rewritten — e.g.
  normalizing a criteria dialect), `false` if the hook fully handled it
  (reply via `ctx.connection.reply`), or nothing to route the original:

  ```ts
  onMessage: (message, { connection }) => {
    if (message.MESSAGE_TYPE === 'CUSTOM_PING') {
      connection.reply(message, { MESSAGE_TYPE: 'CUSTOM_PONG' });
      return false;
    }
  }
  ```

- **`config.defaultEventHandler`** — fallback for commit events with no
  registered handler, so a project with dozens of events can adopt
  incrementally (`defaultEventHandler: () => ({})` acks everything
  unhandled) instead of enumerating ack-only handlers. Unregistered events
  get the router's `MSG_NACK` 404 when unset (see
  [Protocol coverage](#protocol-coverage)). Note `RESOURCES_REQUEST` only
  lists *named* events (`eventHandlers` and `eventSchemas` keys, and the
  tables' `events`) — if the
  client's resource gate blocks an event served only by the default handler,
  name it in `config.eventSchemas` or `config.resources`.

## Two auth transports

Genesis client apps built on `@genesislcap/foundation-auth`'s login UI don't
use the WebSocket `EVENT_LOGIN_AUTH` flow at all — they POST credentials over
plain HTTP to `gwf/event-login-auth`, and GET `gwf/event-login-details` on
every app load for cookie-based silent login
(`foundation-comms/src/connect/sessionRefresh.ts`'s `isAuthenticated()`).
`createMockServer` runs one `http.Server` (via `node:http` + the `ws`
package) that both the WebSocket upgrade and the HTTP login handler
(`handlers/http.ts`, built on Node's `IncomingMessage`/`ServerResponse`)
share, so both transports work against the same port under either runtime.

Both transports answer as auth 8.15.5 does (recorded in
`test/conformance/recordings/showcase-real/`):

- **Login ack** (`EVENT_LOGIN_AUTH_ACK`, and `EVENT_LOGIN_DETAILS_ACK` for the same
  session): `DETAILS` holds `HEARTBEAT_INTERVAL_SECONDS` (`auth.heartbeatIntervalSecs`,
  default 0), `SESSION_TIMEOUT_MINS` (`auth.sessionTimeoutMins`, default 30),
  `REFRESH_TOKEN_EXPIRATION_MINS` (`auth.refreshTokenExpirationMins`, default 7200),
  `LAST_LOGIN_DATE_TIME`, `DAYS_TO_PASSWORD_EXPIRY: 730`, null `NOTIFY_EXPIRY` and `MFA_*`,
  `SYSTEM.DATE` as Java prints a date, and `PRODUCT` (`auth.products`, default auth 8.15.5
  and genesis 8.15.29). Top level: `UPDATE_QUEUE_DETAILS` (a stock ZeroMQ install's),
  `USER` (`AuthUser.loginAck`, else null) and `USER_DETAILS` (`FIRST_NAME`/`LAST_NAME` of
  `AuthUser.user`). HTTP replies carry a `SOURCE_REF` (the request's, or `AUTO_<uuid>`).
- **Failed login:** `EVENT_LOGIN_AUTH_NACK` with one `LoginError` ("Problem logging in")
  and null `REFRESH_AUTH_TOKEN` / `REFRESH_TOKEN_EXPIRATION_MINS`; over HTTP it is a
  **200**, as on GSF, so foundation-auth takes its NACK path rather than its fetch-error
  path. Its `CODE` is auth's `AuthFailure`, checked in auth's order: an unknown user, a
  `DISABLED` one (`AuthUser.status`) and a wrong password `INCORRECT_CREDENTIALS` (401);
  `PASSWORD_EXPIRED` or `PASSWORD_RESET`, whatever the password, `PASSWORD_EXPIRED` (403),
  which sends foundation-auth to its change of password; a right password with another
  status but `ENABLED`, `LOCKED_ACCOUNT` (403); no `PASSWORD`, `NO_COMPATIBLE_PROVIDERS`
  (400); a refresh token no session holds, `UNKNOWN_ACCOUNT` (401). A
  `config.auth.directory` (the platform preset's) finds the users and decides instead,
  over the store.
- **Logout:** `LOGOUT_ACK {OIDC_LOGOUT_URL: null}`, ending that session.
- **`HEARTBEAT_PING` with `VERBOSE: true`:** the pong adds `USER_NAME`, `CREATED_AT`,
  `LAST_RECEIVED` and `LAST_SENT` (heartbeats don't count) and `RESOURCES`, the live
  subscriptions by resource name.
- **`event-login-details` without a live session:** HTTP 401 with the router's `MSG_NACK`.

**Cookie authentication (on by default).** The router runs as GSF's shipped
`genesis-router.kts` does (`cookieAuthentication { enabled = true }`, which Create's blank
project and the showcase keep). An HTTP login sets the `sessionAuthToken`, `refreshAuthToken`,
`sessionId` and `mfaCode` cookies (a failed one clears `refreshAuthToken`, logout clears all
four); the HTTP endpoints read the session from the cookie; a WebSocket upgrade without a live
`sessionAuthToken` cookie gets HTTP 401; and on the socket the cookie authenticates every
message, so `EVENT_LOGIN_AUTH` and `EVENT_LOGOUT` there get `MSG_NACK` 403. So a client (or a
test) logs in over HTTP and opens the socket with the cookies. Pass an object
(`{ httpOnly, secure, sameSite, path, domain, sessionCookies }`) to change the cookie
attributes. `config.auth.cookieAuthentication: false` runs a GSF router without the block: no
cookies, an HTTP request names its session in a `SESSION_AUTH_TOKEN` header, and the socket
logs in with `EVENT_LOGIN_AUTH`. `fidelity: 'legacy'` defaults it off.

> **Behaviour change:** up to this release a socket opened without logging in, and a WS
> `EVENT_LOGIN_AUTH` was answered. Clients that log in over WS need
> `cookieAuthentication: false`; clients on foundation-auth's HTTP login work unchanged.

Set `config.auth.httpAutoLogin: '<username>'` to make `event-login-details`
always ack as that user — the client's silent-login check succeeds on first
load and the login screen never appears, which is usually what you want for
CI/local dev. A request without a live session gets a new one, and with
`cookieAuthentication` its login cookies too, so the socket can open. Leave it
unset to require an explicit `EVENT_LOGIN_AUTH` POST (validated against
`config.auth.users`, or its directory, same as the WS flow) before
`event-login-details` will succeed on a later reload.

An **opt-in** auth gate (`config.auth.requireAuth: true`) rejects WS data
traffic (`DATA_LOGON`, `REQ_*`, `META_REQUEST`, `JSON_SCHEMA_REQUEST`, commit
events) with a `NOT_LOGGED_ON` error until the connection logs in over WS or
the message carries a `SESSION_AUTH_TOKEN` this server issued over **either**
transport. The WS token-refresh login (`EVENT_LOGIN_AUTH` with only
`DETAILS.REFRESH_AUTH_TOKEN`, no credentials — foundation-comms'
`createRefreshTokenMessage`) is honored via the same token registry.

`GET gwf/sso/list` is served too: foundation-auth's login screen fetches it
whenever SSO is toggled on, and treats a failed fetch as fatal — red "Failed
to fetch identity providers" **and** the credentials form hidden — while an
empty `IDPS` list is rejected as well ("Identity provider list empty").
Configure real entries via `config.auth.ssoIdentityProviders`
(`{ ID, DESCRIPTION, TYPE }`, foundation-auth's `IDENTITY_PROVIDER` DTO);
when unset the mock serves one non-functional placeholder so the login
screen still renders with SSO enabled. Clicking a provider navigates to the
real SSO login URL, which the mock does not implement.

The gate is **off by default** for a hard-won reason: clients that log in
over foundation-auth's HTTP transport send **no** `SESSION_AUTH_TOKEN` on any
WebSocket message — session storage's `authToken` is never populated by that
flow, so `message.ts`'s token stamp serializes to nothing (verified from
browser frame captures). A default-on gate would reject every datasource such
a client opens. Enable it only for clients that authenticate over WS.

## Protocol coverage

Envelopes follow the real GSF 8.15.x server (router, dataserver and event
handler Kotlin, checked against tag `v8.15.14`). `fidelity: 'legacy'` restores
the envelopes this engine sent up to 15.47 — see
[Wire fidelity](#wire-fidelity-and-the-legacy-escape-hatch).

| Inbound | Reply | Notes |
|---|---|---|
| `EVENT_LOGIN_AUTH` (WS) | `EVENT_LOGIN_AUTH_ACK` / `_NACK` | |
| `gwf/event-login-auth`, `gwf/event-login-details` (HTTP) | `EVENT_LOGIN_AUTH_ACK` / `EVENT_LOGIN_DETAILS_ACK` | |
| `EVENT_LOGOUT` | `LOGOUT_ACK` | |
| `HEARTBEAT_PING` | `HEARTBEAT_PONG` | |
| `DATA_LOGON` | `LOGON_ACK {SEQUENCE_ID: 0}`, then `QUERY_UPDATE {ROW, MORE_ROWS, ROWS_COUNT, SEQUENCE_ID: 1}` | The first page only — see [Dataserver paging](#dataserver-paging). No matching rows: `{QUERY_UPDATE, MORE_ROWS: false, SEQUENCE_ID: 1}`, with no `ROW` and no `ROWS_COUNT`. A second logon on a live `SOURCE_REF` gets `MSG_NACK` 409 |
| (live push) | `QUERY_UPDATE {ROW, SEQUENCE_ID}` | One row per push, or an `INSERT` and the `DELETE` it evicts from a full moving view; `DETAILS.OPERATION` is `INSERT`/`MODIFY`/`DELETE`, and a `MODIFY` carries only the changed fields. No `MORE_ROWS`, no `ROWS_COUNT` |
| `MORE_ROWS` | `QUERY_UPDATE {ROW?, MORE_ROWS, SEQUENCE_ID}` on the logon's `SOURCE_REF`, plus `MORE_ROWS_ACK` | See [Dataserver paging](#dataserver-paging) |
| `DATA_LOGOFF` | `LOGOFF_ACK` | `MSG_NACK` 404 when the connection has no such subscription |
| `META_REQUEST` | `FEATURE_ACK {DETAILS}` | Dataservers, request servers (by name, without `REQ_`) and events — see [Declared schema](#declared-schema-meta_request-and-json_schema_request) |
| `JSON_SCHEMA_REQUEST` | `JSON_SCHEMA_FEATURE_ACK {INBOUND, OUTBOUND}` | Events, dataservers and request servers — see [Declared schema](#declared-schema-meta_request-and-json_schema_request) |
| `RESOURCES_REQUEST` | `RESOURCES_REQUEST_ACK` | |
| `REQ_<NAME>` | `REP_<NAME> {REPLY, PARAMETRIC_TYPE}`, plus `MORE_ROWS` / `NEXT_OFFSET` / `NEXT_VIEW` on criteria-only servers; a failure is a service `MSG_NACK` | Plain servers select by `REQUEST` fields (exact, `*` wildcards, ranges, arrays) and keyed lookups (`'*'` = any); criteria-only ones page by `OFFSET` and sort by `ORDER_BY` — see [Request servers](#request-servers-req_) |
| `EVENT_<ENTITY>_<VERB>` commit events | `EVENT_ACK` / `EVENT_NACK` | Via `config.eventHandlers`, a table's generic `events`, or `defaultEventHandler`; checked against a declared schema first — see [Generic CRUD events](#generic-crud-events-tabledefevents) |
| Anything naming an unknown resource | `MSG_NACK` 404 | See below |

Each `SEQUENCE_ID` on a subscription is the previous one plus 1. The snapshot
is 1, and each later `MORE_ROWS` page or live push takes the next number.
`LOGON_ACK` is 0, and `MORE_ROWS_ACK` and `LOGOFF_ACK` carry none.

**Unknown resources** are rejected the way the real router rejects them,
before any handler runs:

```json
{
  "MESSAGE_TYPE": "MSG_NACK",
  "SOURCE_REF": "<the request's>",
  "ERROR": [{ "CODE": 404, "TEXT": "Resource X is not mapped to a service", "STATUS_CODE": "404 Not Found" }],
  "DETAILS": { "ERROR": "Resource X is not mapped to a service", "STATUS_CODE": "404 Not Found" }
}
```

`CODE` is the integer status. The resource name `X` is `DETAILS.DATASOURCE_NAME`
for `DATA_LOGON`, the name after `REQ_` for request servers, `DETAILS.FEATURE`
for `META_REQUEST` and `JSON_SCHEMA_REQUEST`, and the `MESSAGE_TYPE` for a
commit event with no handler (and no `defaultEventHandler`). A missing name is
the empty string. The same frame answers two subscription errors:

- `MORE_ROWS` or `DATA_LOGOFF` for a subscription the connection doesn't
  have gets `TEXT` "Dataserver subscription does not exist".
- A repeated `DATA_LOGON` gets `CODE` 409 and `"409 Conflict"`.

This is the change consumers notice most. foundation-comms' socket
**rejects** the pending promise on `MSG_NACK`
(`foundation-comms/src/connect/socket.ts`). The old typed replies resolved it:
`QUERY_UPDATE`, `REP_*`, `EVENT_NACK` or an echoed `META_REQUEST`, each carrying
`ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]`.

## Wire fidelity and the legacy escape hatch

Behaviour that matches the real server is the default. `config.fidelity` is one
switch for the whole server. Its type is `FidelityMode` (`'gsf' | 'legacy'`)
and it defaults to `'gsf'`. `'legacy'` restores what this engine sent up to
15.47, for a consumer built against those envelopes that can't move yet:

```ts
createMockServer({ ...config, fidelity: 'legacy' });
```

| | `'gsf'` (default) | `'legacy'` |
|---|---|---|
| `DATA_LOGON` | `LOGON_ACK`, then the first `MAX_ROWS` rows; `MAX_VIEW: 0` without `MOVING_VIEW: false` is `LOGON_NACK INVALID_PARAMETER` | Every row in one `QUERY_UPDATE`, no `LOGON_ACK`; `MAX_ROWS`, `MAX_VIEW` and `MOVING_VIEW` are ignored |
| No matching rows | No `ROW`, no `ROWS_COUNT` | `ROW: []`, `ROWS_COUNT: 0` |
| Repeated `DATA_LOGON` on a live `SOURCE_REF` | `MSG_NACK` 409; the first subscription keeps running | Replaces the subscription and sends a new snapshot |
| `ROWS_COUNT` | Rows after the query `filter`, before `CRITERIA_MATCH` and the `rowFilter` | Rows after all three |
| Live push | No `ROWS_COUNT`; only for rows the client holds, or as an `INSERT` for a row that is new to it | `ROWS_COUNT: 1`; to every subscription whose criteria match (every DELETE goes to all of them) |
| Live `MODIFY` | The fields that changed since the client was last sent the row, a nulled one as `null`, plus `DETAILS`; nothing when nothing it sees changed (`liveModify: 'full'`: the whole row) ([Live MODIFY](#live-modify-a-diff-of-what-the-client-holds)) | The whole row, every time |
| A full `MAX_VIEW` | A moving view (`MOVING_VIEW`, default `true`) takes a new row and evicts the oldest held one in the same frame; without one, it takes nothing ([Moving view](#a-full-view-the-moving-view)) | Unlimited |
| `MORE_ROWS` | The next page, plus `MORE_ROWS_ACK` | Unanswered (logged as unhandled) |
| `DATA_LOGOFF` | `LOGOFF_ACK`, or `MSG_NACK` 404 | Unanswered |
| `META_REQUEST` / `JSON_SCHEMA_REQUEST` success | `FEATURE_ACK` / `JSON_SCHEMA_FEATURE_ACK` | Echoes the request's `MESSAGE_TYPE` |
| `META_REQUEST` content | The GSF shapes below; event META too | `{NAME, TYPE}` fields, `KEY: []` on dataservers, `REPLY_FIELD` alone on request servers; events are unknown |
| `JSON_SCHEMA_REQUEST` content | Per resource type, as below | One minimal `{DETAILS: {properties: {F: {type}}, required}}` as both INBOUND and OUTBOUND; a query or request-server name resolves to its source table |
| Declared schemas (`TableDef.fields`, query and view `fields`, `derivedTypes`) | Used for metadata and rows | Ignored: metadata is inferred, rows go out as stored |
| Commit events | Committed only on `EVENT_ACK`; `IGNORE_WARNINGS` lets returned warnings commit ([Commit semantics](#commit-semantics-validate-warnings-and-ignore_warnings)) | A handler's writes stay whatever it replies; warnings always NACK |
| Values of declared columns | Held by type; BigDecimals sent as strings with their scale ([Value encoding](#value-encoding)) | As given |
| A change to a table a view joins | Pushed only through joins with `backwardsJoin: true`, as the view rows it inserts, deletes or changes ([Views](#views-the-join-model)) | Every view row pushed as a MODIFY, whatever the join |
| Snapshot row order | Newest first by `RECORD_ID`; `REVERSE` alone gives oldest first; `ORDER_BY` an index ([Dataserver paging](#dataserver-paging)) | The order the table holds the rows in |
| `DATA_LOGON` `ORDER_BY` / `REVERSE` / `FIELDS`, criteria on unexposed fields | Honoured / `LOGON_NACK` | Ignored |
| A `CRITERIA_MATCH` that isn't valid Groovy, is in this engine's `AND`/`OR`/`NOT`/`<>`/`=` dialect, calls a function GSF's validator doesn't allow or one no overload takes | `LOGON_NACK INVALID_CRITERIA` with GSF's message, no subscription ([Strict criteria](#strict-criteria)) | Malformed or unknown: fails open, every row, logged. The dialect and the refused functions: evaluated |
| The criteria functions | GSF's, typed by the declared fields ([Criteria functions](#criteria-functions)) | The same, a date compared with also read as ISO-8601 |
| `REQ_` | As in [Request servers](#request-servers-req_): `PARAMETRIC_TYPE`, criteria-only paging and ordering, `REQUEST` wildcards, ranges and arrays, a reply block, `MSG_NACK` failures | Every matching row in insertion order; `REQUEST` from the top level or `DETAILS`, `'*'`/`null`/`''` match anything, an array value any of its values; `CRITERIA_MATCH` fails open; no `RECORD_ID`/`TIMESTAMP`; errors as a `REP_` with `ERROR` |
| A message with no `SOURCE_REF` or `MESSAGE_TYPE` (or a blank one) | `MSG_NACK` 400: "No source ref provided" on an empty `SOURCE_REF`, else "No message type provided" | Routed like any other message |
| Unknown resource | `MSG_NACK` 404 (the client's promise rejects) | A typed reply with `ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]` (the promise resolves) |
| Login ack | auth 8.15.5's ([Two auth transports](#two-auth-transports)): `HEARTBEAT_INTERVAL_SECONDS`, `UPDATE_QUEUE_DETAILS`, `USER_DETAILS`, `USER: null`, a `SOURCE_REF` on HTTP replies; `event-login-details` answers with the session's own tokens | `HEARTBEAT_INTERVAL_SECS` (default 30), top-level `UPDATE_QUEUE`/`ZERO_MQ_*`, `USER` = `AuthUser.user`, one `PRODUCT` named by `productName`; new tokens on every `event-login-details` |
| Failed login | `EVENT_LOGIN_AUTH_NACK` with a `LoginError`; HTTP 200 | HTTP 401; over WS `STATUS_CODE: '401'`, "Incorrect username or password" |
| `HEARTBEAT_PING` `VERBOSE: true`, `EVENT_LOGOUT` | The pong's verbose fields; `LOGOUT_ACK {OIDC_LOGOUT_URL: null}` | `IS_AUTHENTICATED` only; `LOGOUT_ACK` alone |
| `cookieAuthentication` unset | On: login cookies, 401 upgrade without one, WS login/logout `MSG_NACK` 403 | Off |
| HTTP session, without `cookieAuthentication` | A `SESSION_AUTH_TOKEN` header; no cookies; 401 `MSG_NACK` without a session | The `MOCK_SESSION_ID` cookie; 401 `EVENT_LOGIN_AUTH_NACK NOT_LOGGED_ON` |

`test/legacyParity.test.ts` pins `'legacy'` to replies recorded from the 15.47.0 engine
itself, for a config with no declarations and for the showcase config.

`'legacy'` is a migration aid, not a second supported protocol. Plan to remove
it from your config; it will then be dropped from the engine. It is read on
every message, so changing it on a running server takes effect straight away.

## Declared schema (`META_REQUEST` and `JSON_SCHEMA_REQUEST`)

Metadata comes from the tables dictionary when a table declares one, and is
inferred from the rows (`src/db/metadata.ts`) when it doesn't. **Declared
wins.** A config that declares nothing keeps working, but its types are
guesses (`LONG`/`DOUBLE`/`BOOLEAN`/`STRING`), it has no enum values,
defaults or not-null, and a table with no rows has no fields.

```ts
tables: {
  TRADE: {
    pkField: 'TRADE_ID',
    sequencePrefix: 'TR', // the key is a SEQUENCE: NULLABLE false, OPTIONAL true
    fields: [
      { name: 'TRADE_ID', type: 'STRING' },
      { name: 'QUANTITY', type: 'INT', nullable: false },
      { name: 'SIDE', type: 'ENUM', values: ['SELL', 'BUY'], default: 'BUY', nullable: false },
      { name: 'COMMISSION', type: 'BIGDECIMAL' },
      { name: 'NAME', type: 'STRING', maxSize: 100, minLength: 0, title: 'Name' },
    ],
    generated: [{ field: 'TRADE_NO', kind: 'AUTO_INCREMENT' }], // filled in on insert
    indexes: [{ name: 'TRADE_BY_SIDE', fields: ['SIDE'] }],      // unique ones: DUPLICATE_KEY
    events: ['INSERT', 'MODIFY', 'DELETE'],                      // see Generic CRUD events
  },
},
views: { TRADE_VIEW: { base: 'TRADE', /* joins, derived */ derivedTypes: { TOTAL: 'DOUBLE' } } },
queries: { ALL_TRADES: { source: 'TRADE_VIEW', fields: ['TRADE_ID', 'SIDE', 'TOTAL'],
                          indexes: [{ name: 'BY_SIDE', fields: ['SIDE'] }] } },
requestReplies: { TRADE: { source: 'TRADE', criteriaOnly: true } },
eventSchemas: { EVENT_TRADE_BOOK: { className: 'com.acme.BookInput', fields: [/* FieldDef */] } },
appName: 'showcase', // enum class names: global.genesis.gen.dao.enums.<appName>.<table>.<Field>
```

- **`FieldDef`** `{name, type, nullable?, values?, default?, maxSize?, minLength?, min?, max?, scale?, description?, title?, optional?}`.
  `type` is a dictionary type (`STRING ENUM INT SHORT LONG DOUBLE BIGDECIMAL BOOLEAN DATE
  DATETIME NANO_TIMESTAMP RAW`). Fields are nullable unless declared otherwise; key and
  generated fields default to not null. `description` replaces the JVM class name the real
  server puts there (leave it unset on dates: foundation-forms spots a date by
  `org.joda.time.DateTime`). `optional` decides whether an event payload may leave the field
  out (see events below); an ENUM with no `values` sends no `VALID_VALUES` and no `enum`.
  `scale` is a BIGDECIMAL column's (default 5); values are held and sent by their declared
  type, see [Value encoding](#value-encoding). `min` / `max` are an INT, SHORT, LONG or DOUBLE
  column's `metadata { min = …; max = … }`: they replace the type's own range in event META
  (`MIN` / `MAX`) and the event's JSON schema (`minimum` / `maximum`), so an inbound event
  outside them gets GSF's `VALIDATION_ERROR`.
- **Views** take their base table's fields and `select` aliases, each join's `select` columns
  typed from the joined table at any depth (nullable unless reached from the base through INNER
  joins only; see [Views: the join model](#views-the-join-model)), and `derivedTypes` for
  derived fields (inferred from rows when missing).
  `ViewDef.fields` is the view's `fields { }` block: the view then exposes only those, in that
  order (keep the key in it).
- **`QueryDef.fields`** is the query's `fields { }` block: rows **and** metadata carry only those
  columns, in that order. A declared source's rows always carry every exposed column, `null`
  when unset, as the real dataserver sends them. `rowFilter` still sees the whole row.
- `fieldTypes` (queries, request servers, `eventSchemas`) still overrides a TYPE either way.

What is sent (GSF 8.15.29; `test/recordedSchemas.test.ts` pins every shape to recorded frames,
ignoring only `SOURCE_REF`):

| Resource | `META_REQUEST` `DETAILS` | `JSON_SCHEMA_REQUEST` |
|---|---|---|
| Dataserver | `{TYPE: 'DATASERVER', NAME, FIELD: [{NAME, TYPE, VALID_VALUES?}], INDEXES: [{NAME, FIELDS: 'A B'}]}`. `VALID_VALUES` on ENUMs only; no indexes → `[{NAME: null, FIELDS: 'RECORD_ID'}]`; none at all for a query on GSF's older engine (`legacyEngine`, as `ALL_PROCESSES_STATUS` is) | INBOUND: the `DATA_LOGON` message. OUTBOUND: the `DataLogonReply` `oneOf`, the fields in `$defs['global.genesis.message.core.dataserver.QueryRow']` (all `oneOf [null, type]`, `genesisType`) |
| Request server | Plain: `{REQUEST_FIELD, INDICES: [], CRITERIA_ONLY_REQUEST: false, REPLY_FIELD}`; `REQUEST_FIELD` is `requestFields`, else the key. `criteriaOnly`: `{REQUEST_FIELD: [], CRITERIA_ONLY_REQUEST: true, SORTABLE_FIELDS, CRITERIA_FIELDS, REPLY_FIELD}`, the sortable and criteria fields being the source's fields then `RECORD_ID` and `TIMESTAMP`. `REPLY_FIELD` is the reply block (`fields`), else the source's fields then `RECORD_ID: LONG` and `TIMESTAMP: NANO_TIMESTAMP`, for either kind | INBOUND: top-level `REQUEST` (object or array of the request fields) required, `DETAILS {VIEW_NUMBER, MAX_ROWS, CRITERIA_MATCH}`. OUTBOUND: `properties.REPLY.items` (as `REPLY_FIELD`; nullable fields as `oneOf [null, type]`) |
| Event | `{NAME, TITLE?, DESCRIPTION, FIELD, DEFINITIONS: {}, REPLY, REQUIRES_APPROVAL: false, SCHEDULABLE: false, TYPE: 'EVENT_HANDLER'}`. FIELD items: `NAME, TITLE?, DESCRIPTION (JVM class), NULLABLE, TYPE, JSON_TYPE, OPTIONAL, READ_ONLY, GENESIS_TYPE` plus `MIN/MAX`, `MIN_LENGTH/MAX_LENGTH`, `VALID_VALUES`, `DEFAULT_VALUE`, `ENUM_CLASS`, `DEFAULT` where they apply. `REPLY` describes EventAck/EventApprovableAck/EventNack, copied from the recording | INBOUND: the event envelope, `DETAILS` with `additionalProperties: false` and `required` = the fields that aren't `OPTIONAL`. Nullable fields are `oneOf [schema, null]`; dates are `integer`; BigDecimal is a `string` with a pattern and no `genesisType`. OUTBOUND: the `EventReply` `oneOf` |

Events resolve as `JSON_SCHEMA_REQUEST` always did: `config.eventSchemas`, then
`EVENT_<TABLE>_<VERB>` (longest table name wins) — but only for a registered event (an
`eventHandlers` or `eventSchemas` entry, or a table's `events`); any other `EVENT_*` name gets the router's 404, as on
the real server. `OPTIONAL` is true only when the class property has a default
(PALMetadataUtils.kt): a table's generated DAO defaults every nullable and generated column, so
those are optional; a custom DTO field is optional only with a `default` or `optional: true`
(a nullable DTO field with no default must still be sent). An optional ENUM with no default
reports its first value as `DEFAULT`. Three classes, as the real server reports them:

- **Table events** (a generated DAO): every field sorted by Kotlin property name (`DATE_FIELD`
  before `DATETIME_FIELD`), with titles, lengths, ranges and `GENESIS_TYPE`. `TYPE` is the JVM
  one: a DATE column is `TYPE: 'DATETIME', GENESIS_TYPE: 'DATE'`.
- **Deletes** (`.../_DELETE`, or `pkOnly`): `<Table>.ById`, the key fields only, all mandatory,
  no annotations (`GENESIS_TYPE: null`).
- **Custom DTOs** (`eventSchemas[name].fields`): the declared fields, no annotations,
  `className` as DESCRIPTION; a field with `dao: true` comes from a generated DAO class the DTO
  unwraps (`@JsonUnwrapped`) and has its annotations, as a table event's do (`maxSize` is then
  checked). A DTO field can hold more than one value, as GSF describes and
  checks it (`src/protocol/dtoTypes.ts`):

  ```ts
  { name: 'EMAIL_TO', type: 'ARRAY', unique: true, items: { type: 'STRING' } },   // Set<String>
  { name: 'RULES', type: 'ARRAY', items: { type: 'OBJECT', className: 'com.acme.Rule',
                                           fields: [/* FieldDef */] } },         // List<Rule>
  { name: 'PARAMS', type: 'MAP', items: { type: 'STRING' } },  // Map<String, String>; no items: Any
  { name: 'ENTITY', type: 'ANY' },                              // Any?
  ```

  META gives the type in the field's entry (`VALUE_TYPE`, `KEY_TYPE`, `UNIQUE`, `REFERENCE`) and
  each nested class in `DEFINITIONS`; the JSON schema gives arrays `items` and `uniqueItems` and
  each nested class in `$defs`. An INBOUND schema writes a nested class as GSF writes it for its
  validator, open to other properties, and the handler never sees those keys (GSF's decoder
  drops them); a reply's `$defs` are closed. DETAILS are checked against the INBOUND schema, item
  by item (`$.DETAILS.RULES[0]: ...`). Items are not null unless they say so; an ENUM that isn't a
  generated one names its class in `enumClass`.

The AI assistant (`ai-assistant/src/genesis/event-fields.ts`) builds its write tools from event
META: `NAME`, `GENESIS_TYPE`/`TYPE`, `JSON_TYPE`, `OPTIONAL`, `READ_ONLY`, `TITLE`,
`VALID_VALUES`.

**Behaviour changes for existing configs** (none under `fidelity: 'legacy'`):
`JSON_SCHEMA_REQUEST` by a query or request-server name now returns that resource's own
schema (the `DATA_LOGON` message, or `REQUEST` / `REPLY`), as GSF does, not its source table's
event-shaped one. Event names sort their fields, and `EVENT_*` names with no handler are a 404.
A declared source's rows carry exactly its exposed columns, `null` when unset.

Deviations: a bare table name with no request server of that name still gets the table's
insert-shaped event schema from `JSON_SCHEMA_REQUEST` (this engine's fallback; the real server
answers 404). Dataserver rows still carry neither RECORD_ID nor TIMESTAMP.

## Value encoding

A declared column's values are held by its type, and go on the wire the way
GSF's serializer writes them (`GenesisSetMaskingJsonSerializer.kt`, GSF
v8.15.29: a `DateTime` as epoch millis, a `BigDecimal` as
`value.toPlainString()`):

| Type | Held as (seed rows, inserts, update patches) | On the wire |
|---|---|---|
| `BIGDECIMAL` | A number rounded HALF_UP to the column's `scale` (a number or a numeric string is accepted) | A string with exactly `scale` decimals: `15` → `"15.00000"`, `1234.56789012` → `"1234.56789"` (recorded) |
| `DATE` / `DATETIME` | Epoch millis (a number, a `Date`, `yyyyMMdd`, `yyyyMMdd-HH:mm[:ss[.SSS]]` or ISO-8601; ISO without an offset is UTC) | The number |
| `INT` `SHORT` `LONG` `DOUBLE` `NANO_TIMESTAMP` | A number (a numeric string is read as one) | The number |
| `BOOLEAN` | `true`/`false` (also the strings, any case) | The boolean |
| `ENUM` | As given; a value not in `values` is logged once (the real table can't hold it) | The string |

- **The scale.** A SQL-backed GSF table stores a BIGDECIMAL as
  `NUMERIC(precision, scale)`, `(20, 5)` unless the dictionary says otherwise
  (`BigDecimalSqlPrecisionConfig`, `SQLBaseTableGenerator.kt`), so every value
  comes back with exactly that many decimals, and SendIt rounds a seed value
  HALF_UP before writing it (`BigDecimalCsvTransformer.kt`). `FieldDef.scale`
  says it (`field("FEE", BIGDECIMAL(20, 2))` is `{type: 'BIGDECIMAL', scale: 2}`).
  A view's joined column keeps its table's scale; a derived BIGDECIMAL has no
  column, so it goes out with its own decimals unless its `derivedTypes` entry
  gives a `scale`.
- **Numbers inside.** Criteria, `ORDER_BY`, `REQUEST` values and
  derived fields see numbers, so `COMMISSION > 20` and an index on a BIGDECIMAL
  compare numerically, as GSF's typed values do. A `REQ_` wildcard matches the
  scaled text (`'*.50000'`). Precision is a JavaScript number's, about 15
  significant digits; a `NUMERIC(20, 5)` value beyond that loses its last digits.
- **Events.** An event's schema already demands a BIGDECIMAL as a string and a
  date as an integer (a JSON number for a BIGDECIMAL is `VALIDATION_ERROR`, as on
  GSF, whose validator isn't type-loose). After that check, a declared
  BIGDECIMAL string reaches the handler as a number (unrounded, as GSF hands it
  a deserialized `BigDecimal`); the column scale applies when it is written.
- **What is left as given.** A key column (the table's `pkField`), so lookups by
  the key a seed or a handler used keep working. An integer beyond 2^53, which
  no JavaScript number holds exactly. A value that isn't its type (a date that
  doesn't exist, `'n/a'` in a BIGDECIMAL): nothing is guessed, `Date.parse`
  included. An undeclared table's values (no types to go by), except that a
  `fieldTypes` override making a column a BIGDECIMAL sends it as a string with
  the default scale. A request-server `resolver`'s rows. A view's joined column
  keeps its table's scale when the view's base table is declared; over an
  undeclared base the view's columns are inferred like the rest.
- **Fidelity.** Under `fidelity: 'legacy'` nothing is converted. Seed rows are
  converted when their table is registered and writes when they happen, so
  switching `fidelity` on a running server changes how values are sent, not
  how the rows already stored are held.
- **`ROW_REF`** is the record's `RECORD_ID` as a string in GSF 8.15
  (`GenesisSetMaskingJsonSerializer.kt`: `value.toString()`, recorded) and in
  10.0 alike (it writes a `Long` ROW_REF as a string, v10.0.0-beta4), so there
  is no option for its type (plan decision D1).

## Dataserver paging

`DATA_LOGON` pages the way the real dataserver does (genesis-pal-dataserver's
`ClientOptions` and `QueryClient`):

- **`DETAILS.MAX_ROWS`** (default 250) is the page size. **`DETAILS.MAX_VIEW`**
  (default 1000) is the most rows the view holds across all pages. `MAX_ROWS`
  is clamped to `MAX_VIEW`, and `0` means "no limit" for either. These are
  also foundation-comms' `DatasourceDefaults`, so a grid that sets neither gets
  250 rows and then pages. **`DETAILS.MOVING_VIEW`** defaults to `true` (see
  [the moving view](#a-full-view-the-moving-view)), so an unlimited view needs
  `MAX_VIEW: 0` with `MOVING_VIEW: false`; `MAX_VIEW: 0` alone is `LOGON_NACK
  INVALID_PARAMETER` "MOVING_VIEW can't be true if maxView is set to 0",
  checked before `ORDER_BY` (`ConnectionManager.verifyClientOptions`).
  foundation-comms sends `MOVING_VIEW: false` unless a grid sets
  `moving-view`. A logon with a `VIEW_NUMBER` other than 0 gets a view of one
  page (`MAX_ROWS`) that doesn't move (`ClientOptions.parse`).
- **Row order.** Without `ORDER_BY` the dataserver reads its primary index,
  `RECORD_ID`, **newest first**, and `REVERSE: true` alone makes it oldest
  first, the order the rows were written in (genesis-pal-dataserver
  `PrimaryKeyDispenser.kt`: `if (reverse) Order.ASC else Order.DESC`;
  recorded in `dataserver-order-by`). `ORDER_BY` names one of the query's
  `indexes` and sorts ascending by its fields, ties by `RECORD_ID`; `REVERSE`
  flips that; an unknown index is `LOGON_NACK INVALID_INDEX` (checked before
  the criteria, as `ConnectionManager.verifyClientOptions` does). Without
  `ORDER_BY`, a `CRITERIA_MATCH` that uses the leading fields of one of the
  query's `indexes` makes the dataserver read through that index instead, so
  the rows come in its order (the index matching the most leading fields, the
  first on a tie: `CriteriaFilterBuilder.findBestMatchingIndex`,
  `QueryClientFactory.build`). `REVERSE` is read as GSF reads a flag: `true`, or
  any text `"true"` in any case. Every page follows the same order. A seed brought with the real server's `RECORD_ID`s
  (as strings, above 2^53) sorts by their integer value. `DETAILS.FIELDS`
  narrows the rows to those columns, dropping names the query doesn't expose.
- The snapshot is the first page: at most `MAX_ROWS` rows, and `MORE_ROWS: true`
  when more are waiting. **`ROWS_COUNT`** is on the first page only, and counts
  like the real dataserver's index count: it **ignores `CRITERIA_MATCH` and the
  user's `rowFilter`** (recorded: `ROWS_COUNT: 10` with 6 rows matching), so it
  can be larger than what the client can ever page to. grid-pro and the AI
  assistant already treat it that way. It is taken after the query's own
  `filter`, the where clause the real index is built over; no recording covers
  that case. With no matching rows it is left out, as GSF does.
- The other rows wait in the subscription. **`MORE_ROWS`** sends the next
  `MAX_ROWS` of them as a `QUERY_UPDATE` on the logon's `SOURCE_REF`, with
  `ROW` and `MORE_ROWS` and without `ROWS_COUNT`. The request names the
  subscription in `DETAILS.SOURCE_REF`, or is sent on the logon's own
  `SOURCE_REF`. foundation-comms' `createMoreRowsMessage` sends
  `{MESSAGE_TYPE: 'MORE_ROWS', SOURCE_REF: <new>, DETAILS: {SOURCE_REF: <stream>, VIEW_NUMBER}}`
  and doesn't wait for a reply.
- When `DETAILS.SOURCE_REF` names another `SOURCE_REF` than the request's
  own, `MORE_ROWS_ACK` also answers the request's. It is sent before the page:
  the real router acks while its dataserver is still reading. A request on
  the logon's own `SOURCE_REF` gets no ack, with or without
  `DETAILS.SOURCE_REF`, as on GSF.
- After the last page, `MORE_ROWS` still gets an answer:
  `{QUERY_UPDATE, MORE_ROWS: false}` with no `ROW`. The real server sends the
  same when a read finds nothing.
- **A full view is not the end of the data.** Once the client holds `MAX_VIEW`
  rows, matching rows that don't fit keep `MORE_ROWS: true` — on the page that
  filled the view, and on the empty `{QUERY_UPDATE, MORE_ROWS: true}` page that
  answers every later `MORE_ROWS` (recorded: `MAX_ROWS` 10, `MAX_VIEW` 3 and 10
  rows give 3 rows, then empty pages, all `MORE_ROWS: true`). A client that
  keeps asking while `MORE_ROWS` is true loops. When a live `DELETE` frees a
  slot, the next `MORE_ROWS` fills it with the next row waiting, as the real
  server's read does.
- Subscriptions belong to their connection. Another socket's `MORE_ROWS` or
  `DATA_LOGOFF` for the same `SOURCE_REF` gets `MSG_NACK` 404 and no rows, and
  the owner's next page is untouched. Two sockets may use the same
  `SOURCE_REF`; closing one leaves the other's subscription alone.

**Live changes follow what each client holds.** Like the real dataserver's
client index, every subscription knows which rows it has sent, with the values
it sent (`held`), and which matched rows are still waiting (`pending`: past the
current page, or past `MAX_VIEW`). A row is in scope while it passes the query
`filter`, `CRITERIA_MATCH` and the user's `rowFilter`. For each live change
(`ClientIndex` / `MovingViewClientIndex`, v8.15.29):

| The row is… | In scope after the change | Out of scope, or deleted |
|---|---|---|
| Held by the client | `MODIFY` of what changed pushed; nothing when nothing it sees changed. An `INSERT` of it is ignored | `DELETE` pushed (row identity only) |
| Waiting for `MORE_ROWS`, or not known to the client | `INSERT` pushed with the full row; now held, and no page repeats it. A full view takes it only [as a moving view](#a-full-view-the-moving-view) | Dropped from the waiting rows, nothing pushed |

So a new row arrives as an `INSERT`, and so does a row a MODIFY brings back
into the criteria (recorded: `DELETE` on leaving, `INSERT` on re-entering, same
`ROW_REF`) and a waiting row a MODIFY touches (`processModifyUpdate` →
`putNewRowEntry`: the client index doesn't know the row). Rows the client never
got get no MODIFYs or DELETEs at all. Nothing absorbed uses a `SEQUENCE_ID`.

A new row is pushed as soon as it arrives, whatever the order — also when it
sorts among the rows still waiting (under `ORDER_BY`, or oldest first) — and
no later page sends it again: the real `ClientIndex.processInsertUpdate` sends
it straight away while the view has room, and `NextRowsStrategy` skips rows
the client already holds.

### Live MODIFY: a diff of what the client holds

A live `MODIFY` carries only the fields whose values differ from what that
client was last sent for the row, plus `DETAILS {OPERATION, ROW_REF}`
(recorded: a renamed counterparty is `{NAME, DETAILS}`). A field that became
`null` is sent as `null`, and so is a column the user can no longer see. On a
view this includes a re-derived field and a joined column (recorded:
`{QUANTITY, CREATED_AT: null, TOTAL, ENTERED_BY: null, TRADE_STATUS: null,
DETAILS}` for a trade modify that left out three fields). The primary key
isn't there unless it changed, so a client must merge the row into the one it
holds by `ROW_REF`. GSF diffs the query's previous row against the new one
(`MemoryTableSubscriber.processModify`, `diffToGenesisSet`) and narrows it to
the client's columns (`QueryUpdate.Modified.setModifyFromQueryRow`). This
engine diffs against what each client holds, which is the same row once every
change is broadcast, and still right after a change that wasn't. A `MODIFY`
that changes nothing the client sees, such as a column outside its `FIELDS`,
isn't sent and takes no `SEQUENCE_ID`.

`liveModify: 'full'` (`MockServerConfig.liveModify`, type `LiveModifyMode`)
sends the whole row on every `MODIFY` instead, for a client that can't merge a
partial row yet; which `MODIFY`s are sent doesn't change. `fidelity:
'legacy'` always sends whole rows.

### A full view: the moving view

`DETAILS.MOVING_VIEW` (default `true`) decides what a view that holds
`MAX_VIEW` rows does with a new one (recorded in `live-moving-view`, where the
logon without `MOVING_VIEW` moves too):

- **Moving view.** The row is pushed as an `INSERT`, and the held row with the
  oldest `TIMESTAMP` is evicted as a `DELETE` in the same `QUERY_UPDATE`, after
  the `INSERT`. Oldest is the version the client holds, by `TIMESTAMP` and then
  `RECORD_ID` (`MovingViewClientIndex.timestampIndex`), whatever the order
  the view is in. Newest first, that is the last row of the snapshot; under
  `REVERSE` or `ORDER_BY` it can be any row, and a row a `MODIFY` reached is
  newer than one it didn't. When the new row is itself the oldest, nothing is
  sent. A `MODIFY` of a row the view doesn't hold evicts the same way.
- **`MOVING_VIEW: false`.** The view takes nothing more (`MaxViewReached`).
- Either way, a row the view doesn't take, or evicts, waits for a page only
  when it sorts after the last row a page delivered: the real
  `NextRowsStrategy` reads on from there and never goes back. Newest first, a
  new row sorts before it, and so does every row a page sent, so neither is
  sent again; only a row pushed while it was still waiting can come back. A
  later `DELETE` frees a slot but pushes nothing; the next `MORE_ROWS` fills
  it.

**Behaviour change.** Up to 15.58, a live `MODIFY` carried the whole row and
was sent whenever a change reached a held row, a `MODIFY` of a waiting row
pushed nothing, a full view took every new row and grew past `MAX_VIEW`, and
`MAX_VIEW: 0` alone meant no limit. `liveModify: 'full'` brings back whole
rows; `fidelity: 'legacy'` keeps all of it as it was in 15.47.

Every row's `ROW_REF` is its own record's `RECORD_ID` (a view row's, its base
record's), so live changes are matched to exactly the row the client holds,
whatever a view's `pkField` says.

`VIEW_NUMBER` is accepted and ignored. It selects the real server's paginated
mode, where each page replaces the last; here paging is always sequential.

## Request servers (`REQ_`)

A request server is one of two kinds on GSF 8.15.29, and they read a request
differently. `RequestReplyDef.criteriaOnly` picks the kind, as
`criteriaOnlyRequest = true` does in the GPAL config (genesis-pal-requestserver:
`EntityDbRequestReply.kt` for criteria-only, `RequestReply.kt` otherwise).
`test/requestReply.test.ts` pins the rules below, and the `reqrep-*` conformance
recordings replay against the showcase mock.

```ts
requestReplies: {
  // showcase-reqrep.kts: config { criteriaOnlyRequest = true }
  COUNTERPARTY: { source: 'COUNTERPARTY', criteriaOnly: true },
  // auth-reqrep.kts: request { CODE } reply { CODE DESCRIPTION }
  RIGHT: { source: 'RIGHT', fields: ['CODE', 'DESCRIPTION'] },
  // config { maxRows = 500 }
  BIG: { source: 'BIG', criteriaOnly: true, rowLimit: 500 },
}
```

| | Criteria-only (`criteriaOnly: true`) | Plain |
|---|---|---|
| `REQUEST` | Must be a top-level object (missing, `null`, an array or inside `DETAILS`: `MSG_NACK INTERNAL_ERROR`, as recorded); its fields are ignored | Top level only (`DETAILS.REQUEST` is never read); missing means every row. Each field selects rows: an exact value (converted to the field's type; `null` matches nulls, and nothing on a non-null field), a string with `*` (a wildcard, below), or `FIELD_FROM` + `FIELD_TO` (an inclusive range; one alone matches nothing). A field the source doesn't have matches nothing unless its value is `null`. An array runs each object and concatenates the results (`[]`: no rows) |
| `CRITERIA_MATCH` | Filters the rows. Malformed — refused by the Groovy check, or unparseable and not known to be valid Groovy ([Strict criteria](#strict-criteria)): `MSG_NACK INTERNAL_ERROR` (recorded). An unknown field: `REPLY: []` (recorded), and `REQUEST_FAILED` "Paging not supported on lazy criteria evaluation" with an `OFFSET` past 0 | Filters the rows. Malformed (as for criteria-only): `MSG_NACK REQUEST_FAILED` with the parse error. An unknown field matches nothing |
| `MAX_ROWS` | The page size: `DETAILS.MAX_ROWS`, else `rowLimit`, else 10000. Below 1: `REQUEST_FAILED` "maxRows should be greater than 0" | The most rows each `REQUEST` object returns, same defaults. No signal that more exist |
| `OFFSET` / `VIEW_NUMBER` | `OFFSET` skips that many rows. `VIEW_NUMBER` n (from 1) is the page at `MAX_ROWS × (n − 1)`, with `NEXT_VIEW: n + 1` while the page has rows. Both, or either negative: `REQUEST_FAILED` with GSF's text | Ignored |
| `ORDER_BY` | `'FIELD [ASC\|DESC], …'` (grid-pro sends `'NAME DESC'`, `'RECORD_ID ASC'`); an index name or `PK` stands for its fields; ties go by `RECORD_ID` ascending, nulls sort low. Malformed, or a field the source doesn't have: `REQUEST_FAILED` with GSF's text (`Order fields [Asc(field=X)] not found on table T`). Without it, a first page comes in the table's order (oldest first, recorded) and a later page by primary key | Ignored: rows come in primary-key order (recorded: `RIGHT` in `CODE` order), or the server's `order` (a comparator, for a Kotlin reply that reads an index or reads it backwards) |
| Reply | `{REPLY, PARAMETRIC_TYPE, MESSAGE_TYPE, NEXT_VIEW?, NEXT_OFFSET?, MORE_ROWS}`: `MORE_ROWS` always, `NEXT_OFFSET` only while it is `true`; past the end `REPLY: []` (recorded: `reqrep-paging`, `reqrep-max-rows`) | `{MESSAGE_TYPE, SOURCE_REF, PARAMETRIC_TYPE, REPLY}` |

- **Wildcards** (`WildcardFilter.kt`): any string value containing `*`. With `*`
  at both ends it is "contains", at the start "ends with", at the end "starts
  with", after trimming every leading and trailing `*`; `*` alone matches
  everything. Case-sensitive. A `*` only in the middle matches nothing. The
  row value is compared as Kotlin's `toString()`: `null` reads `"null"`, a
  whole `DOUBLE` `"2.0"`, a date its ISO-8601 form.
- **`PARAMETRIC_TYPE`** is the source's generated class:
  `global.genesis.gen.dao.<Table>` (`global.genesis.gen.dao.Counterparty`), or
  `global.genesis.gen.view.entity.<View>` for a view.
- **Rows** carry the reply block (`fields`) in its order, `null` where unset;
  without one, the source's columns then `RECORD_ID` and `TIMESTAMP`.
  `REPLY_FIELD` and the JSON schema's `REPLY` say the same. An undeclared
  source's rows go out as stored.
- **`filter` and `auth`** are applied to the rows the read returns, so a
  criteria-only server reads on until one row past the page has passed them,
  and `NEXT_OFFSET` is the request's `OFFSET` plus the rows that took, minus
  one — not `OFFSET + MAX_ROWS`. A `VIEW_NUMBER` page read this way keeps
  what passes of one page and sends no `MORE_ROWS` (GSF can't tell).
- **Failures** are a service `MSG_NACK`
  (`{WARNING: [], ERROR: [{'@type': 'StandardError', CODE, TEXT, STATUS_CODE}], MESSAGE_TYPE: 'MSG_NACK'}`),
  which foundation-comms rejects the request's promise with. A `resolver`'s
  `NackError` keeps its items; anything else it throws is `GENERIC_ERROR`
  with the message (`AbstractCustomReqRep.kt`).
- **Typed request servers.** A GPAL `requestReply<In, Out>` whose Kotlin
  builds the reply (GSF's `CustomReqRep`) is a `resolver` with
  `typed: { request: { className, fields }, reply: { className, fields } }`,
  the classes' fields declared as an event DTO's are, in the order GSF lists
  them (by name, but for an unwrapped class's). Its META is GPAL's shape:
  `{NAME, DESCRIPTION, FIELD, DEFINITIONS, REPLY: {NAME: 'REP_<NAME>',
  DESCRIPTION, FIELD, DEFINITIONS}, CRITERIA_ONLY_REQUEST: false,
  SORTABLE_FIELDS: null, CRITERIA_FIELDS: null, TYPE}`, the request class in
  `FIELD`, the reply class in `REPLY`, each field described as an event's is,
  not `REQUEST_FIELD` / `REPLY_FIELD`. `typed.meta` is the META a real server
  sends, sent as it is, for classes the fields can't describe whole (a sealed
  class's subtypes, a DAO's annotations); the fields then describe only the
  JSON schema. Its `REP_` is `{REPLY, MESSAGE_TYPE,
  PARAMETRIC_TYPE}` with the reply class as `PARAMETRIC_TYPE`;
  `CRITERIA_MATCH` filters the reply rows (a malformed one fails open), then
  at most `MAX_ROWS` go out (`rowLimit`, else 10000), with no paging
  (`AbstractCustomReqRep.kt`). The JSON schema takes the classes' fields
  (not recorded).
- A `CRITERIA_MATCH` this engine's grammar doesn't model but that GSF's
  validator passes (`-QTY < -5`, `QTY == 5L`) still fails open, logged, as on
  the dataserver. What the validator refuses (`NAME in ['A']`, `QTY * 2 > 1`,
  a function off its whitelist), or a call no overload takes, is refused as a
  malformed criteria ([Strict criteria](#strict-criteria)).

What clients see:

- **grid-pro's server-side and infinite row models** need a criteria-only
  server (they refuse a resource whose `META_REQUEST` says
  `CRITERIA_ONLY_REQUEST: false`), and page it by `OFFSET` with
  `ORDER_BY: '<rowId> ASC'` by default. The showcase mock's request servers
  are criteria-only, as the real showcase's are.
- **grid-pro's client-side datasource** keys `REP_` rows by `RECORD_ID`
  unless `row-id` is set. A server whose reply block leaves `RECORD_ID` out
  (like `RIGHT`) can't feed it without a `row-id`: that is GSF's behaviour too.
- **The AI assistant** reads with `REQUEST: {}` and
  `DETAILS: {MAX_ROWS, CRITERIA_MATCH}`, and takes `MORE_ROWS` when sent,
  else a full page, as "there may be more".

Deviations: `VIEW_NUMBER` and `OFFSET` are as GSF's SQL engine answers them
(non-SQL engines send no `MORE_ROWS` or `NEXT_OFFSET`); request field aliases,
`criteriaTemplate` and timeouts are not modelled; the plain kind always reads
by primary key, where GSF reads through the index a `REQUEST` names (the same
rows, possibly in that index's order); an untyped resolver's rows are not
filtered by `CRITERIA_MATCH` or capped by `MAX_ROWS`.

## Entity-management (`@genesislcap/foundation-entity-management`) support

`<entity-management>`'s create/update forms and delete confirmation don't use
`META_REQUEST` at all — they call `JSON_SCHEMA_REQUEST` for the *event name*
(e.g. `EVENT_COUNTERPARTY_INSERT`), and read the reply's
`INBOUND.properties.DETAILS.properties` to know which fields to render (for
insert/update) or which fields to copy from the selected row into the commit
payload (for delete). It's a completely different reply shape from
`META_REQUEST` (`{INBOUND, OUTBOUND}` draft-07 JSON Schema, not
`{DETAILS: {FIELD}}`) — route it through the same handler as `META_REQUEST`
and every entity-management form silently renders with zero fields, and
delete can never build a payload. No error, no console warning — just a dead
button.

`handleJsonSchemaRequest()` (`src/handlers/jsonSchema.ts`) resolves this with
zero config for the common case: `EVENT_<TABLE>_<VERB...>` is matched against
`config.tables` (longest match wins), and any event name matching
`.../_DELETE(/...)?/` gets a PK-only schema (matching what
`EntityManagement.confirmDelete` actually needs) while everything else gets
every field on that table (see [Declared schema](#declared-schema-meta_request-and-json_schema_request)
for what each property carries). This covers naming variants like
`EVENT_COUNTERPARTY_INSERT_WITH_APPROVAL`, `EVENT_TRADE_MODIFY_PRICE`, or
`EVENT_TRADE_INSERT_FAIL` without per-event config. Override via
`config.eventSchemas['<eventName>'] = { table, pkOnly, fieldTypes }` for
names that don't follow the convention.

The `FEATURE` doesn't have to be an event name. It resolves, in order: an
entry in `config.eventSchemas`, a **dataserver** name (`ALL_TRADES`: the
`DATA_LOGON` schema, its rows in `OUTBOUND.$defs[...QueryRow]` — what
`<foundation-filters>` reads), a **request server** name (`TRADE`, without
`REQ_`: its rows in `OUTBOUND.properties.REPLY.items`), an
`EVENT_<TABLE>_<VERB>` event, and finally a bare **table name** (the table's
insert-shaped schema — a fallback the real server doesn't have). Under
`fidelity: 'legacy'` a query or request-server name resolves to its source
table's fields instead, as up to 15.47.

Everything else `<entity-management>` needs — resource discovery
(`RESOURCES_REQUEST`), grid metadata (`META_REQUEST`), row data (`DATA_LOGON`
or `REQ_<name>` depending on `datasourceType`), and the actual
insert/update/delete commit — reuses the exact same handlers as any other
project on this engine; there is no entity-management-specific commit
envelope.

## Overriding or extending a dataserver or event handler

`createMockServer()` returns a handle with `set*`/`get*`/`extend*` methods
for every config section — use these instead of forking a shared config
object when you only need to change one thing:

```ts
import { createMockServer, NackError } from '@genesislcap/mock-server';
import { config as baseConfig } from './showcase-config.ts';

const server = createMockServer(baseConfig);

// Override a dataserver (query) entirely — e.g. point it at a different
// source, or add fieldTypes overrides.
server.setQuery('ALL_TRADES', { source: 'TRADE_VIEW', fieldTypes: { PRICE: 'DOUBLE' } });

// Override an event handler entirely — e.g. force a scenario for one test.
server.setEventHandler('EVENT_TRADE_INSERT', () => {
  throw new NackError({ TEXT: 'Simulated outage', FIELD: 'COUNTERPARTY_ID' });
});

// Extend (wrap) the existing handler instead of replacing it — augment(current)
// gets the handler currently registered (undefined if none), and returns the
// new one. Useful for adding a side effect, a delay, or partial validation
// without re-implementing the whole handler.
server.extendEventHandler('EVENT_TRADE_INSERT', (original) => (details, ctx) => {
  console.log('inserting trade', details);
  return original(details, ctx);
});

// Same pattern for requestReplies, tables, and views (setTable/setView also
// register the change into the live Store, not just config).
server.extendQuery('ALL_TRADES', (current) => ({ ...current, fieldTypes: { ...current.fieldTypes, SIDE: 'STRING' } }));
```

These mutate the server's own config object in place, and every handler
resolves `config.eventHandlers[name]`/`config.queries[name]` fresh on each
message — so changes take effect immediately on an already-`start()`ed
server, no restart needed. This is deliberate: it lets a test inject a
one-off failure or edge case for a single scenario (e.g. "force this insert
to NACK") and then move on, without spinning up a second server or
duplicating the whole config for one different handler.

For compile-time composition (a new project starting from the showcase's
config but changing a handful of things up front), plain object spreading
works just as well and needs no special API:

```ts
import { config as baseConfig } from '@genesislcap/showcase-mock-server/config.ts';

export const config = {
  ...baseConfig,
  eventHandlers: { ...baseConfig.eventHandlers, EVENT_TRADE_INSERT: myOverride },
};
```

## Protocol-correctness pitfalls this engine handles for you

These are real bugs that make a real Genesis client hang silently (no error,
no timeout) if you get them wrong — see the full write-up this package was
extracted from for how each was actually found:

1. **Every response echoes the request's `SOURCE_REF`.** `Connection.reply()`
   (`src/protocol/connection.ts`) does this automatically — never build a raw
   response object yourself without going through it. A response missing
   `SOURCE_REF` leaves the client's pending promise unresolved forever.
2. **`EVENT_NACK` and request-server `MSG_NACK` errors are always an `ERROR` array of items** —
   uppercase keys (see [NACK vocabulary](#nack-vocabulary)). Throw `NackError` from an event handler or
   request-reply resolver rather than hand-rolling this shape; a lowercase
   `{error, details}` object crashes the real client's `.forEach`. (The
   router's `MSG_NACK` uses the same array, with an integer `CODE` and a
   `STATUS_CODE` — `src/protocol/msgNack.ts` builds it.)
3. **Commit-event acks are the GENERIC `EVENT_ACK`/`EVENT_NACK`** — never
   `<EVENT_NAME>_ACK`/`_NACK`. The reply is correlated purely by
   `SOURCE_REF`; real consumers match the MESSAGE_TYPE string exactly
   (`foundation-comms`' `kv.service` checks `=== 'EVENT_ACK'`,
   entity-management and the showcase's `customEvents.ts` check
   `=== 'EVENT_NACK'`). Get this wrong and every successful commit renders
   as a failure toast while every NACK passes as silent success.
   `handleCommitEvent()` (`src/handlers/commitEvent.ts`) does this for you.
4. **`DATA_LOGON` snapshot, `MORE_ROWS` pages and later live pushes share one
   envelope** (`MESSAGE_TYPE: 'QUERY_UPDATE'`, the logon's `SOURCE_REF`; only
   the snapshot carries `ROWS_COUNT`, and live pushes carry no `MORE_ROWS`) —
   the client distinguishes insert/amend/delete purely via each row's
   `DETAILS.OPERATION` (`'INSERT' | 'MODIFY' | 'DELETE'`, no other vocabulary
   is recognized) plus `DETAILS.ROW_REF` for row identity. `stampRow()`
   (`src/protocol/rowUpdate.ts`) does this for you — always route pushes
   through `broadcast()`/`broadcastTableChange()`, never `connection.send()`
   directly.
5. **Datasource `init()` needs `TYPE: 'DATASERVER'` plus a non-empty `FIELD`
   array** or the client silently reports "no fields returned" and gives up.
   Declare the table's `fields` and it never is (see
   [Declared schema](#declared-schema-meta_request-and-json_schema_request)).
   Without them, `deriveFields()` (`src/db/metadata.ts`) infers this from the rows' own JS
   types — falling back to the registration-time seed rows when every live
   row has been deleted, so a runtime delete can't collapse a resource's
   schema to zero fields. Numeric inference scans every row (a column is
   `DOUBLE` if any value is fractional), but a column whose values are *all*
   whole numbers still infers as `LONG` — if it's semantically a `DOUBLE`
   (e.g. a NOTIONAL that happens to be round in the seed data), say so via
   the `fieldTypes` override on the query/request-reply.
6. **`CRITERIA_MATCH` needs more than `==`.** `compileCriteria()`
   (`src/db/criteria.ts`) supports `== != > >= < <= && || !` and
   parentheses (and, under `fidelity: 'legacy'` only, its own `AND OR NOT`,
   `<>` and `=`: see [Strict criteria](#strict-criteria)) via a small typed grammar (not
   `eval`/`new Function` — a malformed criteria string can only fail to
   parse, never execute arbitrary JS). It evaluates the calls GSF allows, as
   GSF computes them (see [Criteria functions](#criteria-functions)): every
   function on GSF's `Expr` whitelist, and the field methods `startsWith`,
   `equals`, `equalsIgnoreCase`, `contains`, `containsIgnoreCase`,
   `containsWordsStartingWithIgnoreCase` and `dateIsToday`
   (pbc-notify-ui's inbox subscribes with `((ALERT_STATUS.equals("NEW")))`).
   A criteria GSF refuses — not valid Groovy, a function off its whitelist, a
   call no overload takes — is refused here too: `DATA_LOGON` gets
   `LOGON_NACK INVALID_CRITERIA` and a request server a `MSG_NACK` (see
   [Strict criteria](#strict-criteria)). Any other criteria this grammar
   doesn't cover — it may be valid Groovy — fails open instead of crashing
   the handler: `compileCriteria()` logs a warning and returns a match-all
   predicate, so `DATA_LOGON` still responds (unfiltered) rather than leaving
   the client's grid stuck on "Loading...". The exported `compileCriteria()`
   and `filterByCriteria()` evaluate as GSF does by default;
   `{ types }` (a `ReadonlyMap` of field → Genesis type) types the fields as
   GSF's entity does, and `{ lenient: true }` evaluates as this engine did up
   to 15.47 (`fidelity: 'legacy'` uses it — see
   [Strict criteria](#strict-criteria)).
7. **Composite-key AMEND/DELETE matching needs a fallback beyond the PK.**
   `Store.findRow()` (`src/db/store.ts`) tries the configured PK field, then
   any single field that uniquely identifies one row, then the full
   combination of provided fields — needed for tables where no single field
   is unique on its own.

## Strict criteria

GSF parses every `CRITERIA_MATCH` as Groovy before it opens a subscription
(`CriteriaFilterBuilder.validateAndParseCriteriaOptions`:
`AstBuilder().buildFromString(criteria)`, GSF v8.15.29, Groovy 3.0.25). One
that doesn't parse gets `LOGON_NACK` alone, no `LOGON_ACK` and no subscription,
so a later `DATA_LOGOFF` is the router's 404 (recorded:
`dataserver-invalid-criteria`):

```jsonc
{ "MESSAGE_TYPE": "LOGON_NACK", "WARNING": [], "ERROR": [{
  "@type": "StandardError", "CODE": "INVALID_CRITERIA", "STATUS_CODE": "400 Bad Request",
  "TEXT": "MultipleCompilationErrorsException : startup failed:\nScript1791….groovy: 1: Unexpected input: '<EOF>' @ line 1, column 19.\n   NAME == ((( 'oops'\n                     ^\n\n1 error\n" }] }
```

**The engine's own dialect is refused too.** This grammar also takes `AND`,
`OR` and `NOT` (any case), `<>` and a single `=`, which Groovy doesn't, and it
reads a dotted name as a field and a `$` in a double-quoted string as text.
(foundation-criteria puts every string value in double quotes, so a filter
value with a `$` in it, such as `US$`, is refused by GSF, and now here.)
GSF refuses all of them, at one of three stages
(`CriteriaFilterBuilder.getCriteriaFilter`): Groovy's lexer or parser
(`A == 1 AND B == 2`, `A <> 1`, `"US$"`), its `CriteriaValidatorVisitor` once
Groovy has read them some other way (`NOT x` is a call on an implicit `this`,
`A = 1` an assignment, `A.B` a property, `"a$b"` a GString), or the
`@CompileStatic` compilation of the filter (`Expr.f(x) AND y` calls `AND` on a
Boolean). `src/db/criteriaDialect.ts` gives GSF's text for the shapes clients
write, with every other validator error in the order GSF lists them (an
unknown field too, when the query's fields are known). It was run side by
side with Groovy 3.0.25 and genesis-db 8.15.29's validator on about 1,300
probe criteria: every text GSF's parser or validator gives matches. A
compilation failure's text is the criteria as GSF compiled it, each field
replaced by its getter:

```text
Error compiling criteria expression: Failed to compile criteria expression Expr.containsIgnoreCase(row.getName(), 'a') AND row.getQty() > 1
```

**So are operands side by side**, most often a double quote left unescaped
in a double-quoted value: `NAME.equals("say "hi"")` is `"say "`, `hi` and `""`.
Groovy reads such a run as a command expression (`a b c` is `this.a(b).c`)
where one may start, which GSF's validator then refuses, and as a syntax error
anywhere else (`src/db/criteriaJuxtaposition.ts`). The texts match Groovy
3.0.25 and genesis-db 8.15.29 for the shapes clients write: a call's only
argument, a later argument, after a binary operator. A type beside a name is
no such pair: a cast (`(int) QTY`), a declaration (`String x = ...`) or a
closure parameter, which GSF's validator refuses with its own text, and so
does this engine. Any other shape is refused with an approximate text.

**So is a call GSF's validator doesn't allow.** Only the `Expr` functions on
its whitelist (see [Criteria functions](#criteria-functions)) and seven field
methods pass; anything else — `Expr.foo(X)`, `Expr.stringDateTime`, a call on
a class (`Math.abs(QTY)`) or on an implicit `this` (`foo(X)`) — is
`LOGON_NACK INVALID_CRITERIA` with the validator's text, every error in
source order:

```text
GenericCriteriaValidationException : Criteria validation failed: Method call Expr.foo is not supported,Variable FOO is not allowed in criteria expression
```

That includes the functions this engine evaluated before GSF's semantics —
`Expr.contains`, `Expr.equals`, `Expr.equalsIgnoreCase`, `Expr.startsWith`,
`Expr.endsWith`, `Expr.dateTimeIsEqual`, `FIELD.endsWith`, and an `Expr`
function called as a field method (`TRADE_DATE.dateIsEqual(...)`).
foundation-criteria's deprecated `Serialisers.endsWith` still emits one of
them (`Serialisers.contains` used to emit `Expr.contains`). **And a call no
overload takes**, which GSF's `@CompileStatic` compilation refuses: the wrong
number of arguments (`Expr.containsIgnoreCase(NAME)`), an ambiguous `null`
(`Expr.dateIsToday(null)`), and — where the fields' types are declared — an
argument of the wrong type (`Expr.containsIgnoreCase(QTY, '1')` on a `LONG`),
a field method the field's type lacks (`QTY.startsWith('1')`), or a
relational operator between types that don't compare (`QTY > '5'`,
`TRADE_DATE > 1.5`).
Names and strings that merely contain a keyword (`NOTES`, `ORDER_ID`,
`'x AND y'`) are not the dialect, and neither is a keyword used where a value
goes (`AND == 1`: GSF's unknown-field check answers that). Request servers
refuse the dialect as any malformed criteria: `MSG_NACK INTERNAL_ERROR`, or
`REQUEST_FAILED` with GSF's text on a plain server.

**And Groovy the grammar doesn't read, as GSF's validator answers it.**
`src/db/groovyValidator.ts` parses such a criteria as Groovy does (Groovy
3.0.25's precedence) and repeats the validator's walk over it: a list
(`NAME in ['A']`: "List expressions are not supported in criteria"),
arithmetic (`QTY * PRICE > 10`: "Binary operator * not allowed in criteria
expression"), a ternary, an Elvis, a range, a map, a cast, a closure, `=~`,
`===`, `instanceof`, a subscript, `++`, a property, every error in the order
the validator meets it. A constructor call other than `BigDecimal`,
`Integer`, `Long`, `Double` and `String` is refused ("Constructor calls for
java.util.Date are not allowed in criteria expressions"), one of a class
Groovy can't resolve fails to compile ("unable to resolve class Foo"); those
five are evaluated (`QTY > new BigDecimal('1.5')`). Checked against Groovy
3.0.25 and genesis-db 8.15.29's validator (`test/groovyValidator.test.ts`).

This is the default. This engine's criteria grammar is still smaller than
Groovy's, so it can't refuse whatever it fails to parse: `-QTY < -5` or
`QTY == 5L` are valid Groovy the validator passes and it doesn't evaluate.
`src/db/groovySyntax.ts` refuses only the mistakes it can name the way Groovy
does: an operator with nothing after it (`QTY > 1 &&`), a parenthesis left open
or closing nothing, an operator where an operand must start (`== 'x'`,
`A && || B`), an unterminated string (an unescaped `'` in `'O'Brien'`), and a
`#`. The text, line and column are Groovy's for those cases (checked by running
Groovy 3.0.25 on them; in a few ANTLR names a different token at the same
column). Anything else it doesn't understand gets no verdict, and the criteria
fails open as before: also a `/` anywhere (a slashy string such as
`/O'Brien/`, or a comment), and nesting too deep to check. Request servers
refuse what this check refuses, and what their grammar can't parse unless the
check found it valid Groovy (`-QTY < -5`), which fails open there too
(see [Request servers](#request-servers-req_)).

Under `fidelity: 'legacy'` criteria are evaluated as up to 15.47
(`src/db/legacyCriteria.ts`; `compileCriteria(criteria, { lenient: true })`):
a malformed one, a bare field or an unknown function fails open; the
dialect is evaluated (`AND` as `&&`, `<>` as `!=`, `=` as `==`); comparisons
are JavaScript's (`QTY > -1` holds for a null `QTY`, `QTY == null` misses a
row without the field); a function answers `false` rather than throw, so
`!STATUS.equals('NEW')` keeps a row with no `STATUS`; `dateTimeIsAfter` and
`dateTimeIsBefore` are inclusive; dates are also read by `Date.parse`. The
GSF functions 15.47 didn't have (`containsWordsStartingWithIgnoreCase`,
`isNullOrBlank`, `notNullOrBlank`, `dateIsAfter`, `dateIsBefore`,
`dateTimeIsInRange`) are evaluated in that style instead of failing open.
There is no separate option: a `CRITERIA_MATCH` the real server can't compile
is a client bug the mock shouldn't hide.

## Criteria functions

What each call computes is GSF's (`Expr.java`, `TimeUtils.java`,
`ExtraOperators.kt`, `TimeRange.kt`, genesis-commons v8.15.29;
`src/db/gsfExpr.ts`), with every overload: GSF compiles a criteria against
the entity's Java types, so a `STRING` or `ENUM` field is a `String`, `DATE`
and `DATETIME` a Joda `DateTime`, `LONG` a `Long`, `INT` an `Integer` (which
GSF widens where a function takes a `Long`). The
evaluator does the same with the declared types; a field without one is read
by its value (a number as a `Long` of epoch millis, a string as a `String`,
also read as ISO-8601 when it isn't one of GSF's formats, as mock rows often
hold). GSF runs in UTC, and so do these.

| Function | What it does |
|---|---|
| `Expr.containsIgnoreCase(text, search)`, `FIELD.containsIgnoreCase(search)` | `text` contains `search`, ignoring case. `null` either side: `false`; `''`: `true`. A mock row's list value (`['USD', 'GBP']`): membership |
| `Expr.containsWordsStartingWithIgnoreCase(text, search[, delimiter])`, and as a field method | A word of `text` — split on the delimiter's first character, `' '` by default — starts with `search`, ignoring case (grid-pro's "Word starts with": `'Tra'` finds `Global Trade Ltd.`, not `Market Solutions`). `null`: `false` |
| `Expr.isNullOrBlank(text)`, `Expr.notNullOrBlank(text)` | `null`, or only whitespace (`String.isBlank`) |
| `Expr.dateIsToday(date)`, `FIELD.dateIsToday()` | The date's UTC day is today |
| `Expr.dateIsEqual / dateIsBefore / dateIsAfter / dateIsGreaterEqual / dateIsLessEqual(date, comparableDate)` | By day. A `DateTime` or `Long` date is compared with a `yyyyMMdd` date only; a `String` date and its comparable may use any of GSF's formats (below) |
| `Expr.dateTimeIsAfter / dateTimeIsGreaterEqual / dateTimeIsBefore / dateTimeIsLessEqual(date, comparableDate)` | By instant. `After` and `Before` are strict: foundation-ui's date-range filter (`dateTimeIsAfter(F, min) && dateTimeIsBefore(F, max)`) leaves out both bounds |
| `Expr.dateTimeIsInRange(date, timeRange, timeContext)` | The date is in the `HOUR`, `DAY`, `WEEK` (from Monday), `WEEK_DAY` (a weekend's current one is empty, its previous one Friday), `MONTH` or `YEAR` that is `CURRENT` or `PREVIOUS`. The names are case sensitive |
| `Expr.longLocalDate(millis)`, `Expr.stringLocalDate(date)`, `Expr.longDateTime(millis)`, `Expr.stringDateTime(date)` | A day or an instant, to compare (`Expr.longLocalDate(D) == Expr.stringLocalDate('20240101')`). `stringDateTime` exists, but GSF's validator refuses it |
| `FIELD.startsWith(prefix[, offset])`, `FIELD.equals(value)`, `FIELD.equalsIgnoreCase(text)`, `FIELD.contains(text)` | Java's. They dereference the field: a `null` one throws. `equals` wants the same class: a `LONG` field never `equals(5)` (an `Integer`) |

GSF's date formats (`TimeUtils.ALLOWED_DATE_PARSER`) are `yyyyMMdd-HH:mm:ss.SSS`,
`yyyyMMdd-HH:mm:ss`, `yyyyMMdd-HH:mm`, `yyyyMMdd` and `yyyy-MM-dd`. Joda reads
fewer digits than a pattern shows (`2024-1-1`, `20240101-9:5`) and up to three
of a fraction (`.5` is 500 ms); nothing else parses — no `T`, no offset, no
spaces. A `DateTime` field also compares with a date string, epoch millis or
another `DateTime` (`TRADE_DATE >= '20240101' && TRADE_DATE < '20240102'`,
ExtraOperators.kt); `==` takes one naming its instant.

A call that throws in GSF — an unparseable date, a `null` a function
dereferences, an unknown `TimeRange` — makes the row not match, however the
criteria goes on: `!Expr.dateIsEqual(TRADE_DATE, '2024-01-01')` matches no
row. The evaluator's verdicts were checked against GSF's own: about 570
criteria compiled and run by Groovy 3.0.25 with genesis-commons 8.15.29 and
Joda-Time 2.14.0 over five rows (`test/gsfCriteriaCases.ts`).

## Live criteria membership, and known deviations

A live **MODIFY** that moves a row out of a subscription's `CRITERIA_MATCH`, or
out of the query's `filter`, is pushed as a **DELETE** carrying only the row's
identity to a client that holds the row, and a MODIFY that brings a row back
in is pushed as an **INSERT** (`resolveSubscriptionOperation()` and
`viewUpdate()` in `src/server.ts`; see
[Dataserver paging](#dataserver-paging)). If the predicate throws, the push
is skipped instead: a broken filter must never evict rows. **Deletes** bypass
the criteria predicate; they reach every client that holds the row. (Under
`fidelity: 'legacy'`, every DELETE reaches every subscription, and a row
entering the criteria is pushed as a MODIFY.)

Where this engine still differs from the real server:

- **The read position** a row must sort after to wait for a page is the last
  row a page delivered. GSF's is the last row it read, which is further on
  when the rows after the last one delivered didn't match.
- GSF's client index also drops a `MODIFY` whose row isn't newer, by
  `TIMESTAMP`, than the one the client holds (`modifyRowEntry`), and dates a
  view row a joined table's change reaches with the time of that change.
  Neither is modelled: broadcasts here arrive in order, and such a view row
  keeps its base row's `TIMESTAMP`, which only matters for which row a
  [moving view](#a-full-view-the-moving-view) evicts.
- **`ROWS_COUNT` under a criteria-chosen index.** When a criteria makes the
  dataserver read through an index (see [Dataserver paging](#dataserver-paging)),
  GSF narrows the read to the range the criteria allows on that index
  (`IndexBuilderVisitor`), and `ROWS_COUNT` counts that range only. The mock
  counts the whole query, as it does for any other criteria.
- **Criteria types** come from declared fields (and `fieldTypes` /
  `derivedTypes`) only. A field without one is read by its value, and a call
  or comparison it takes part in isn't type-checked: GSF would refuse
  `Expr.containsIgnoreCase(QTY, '1')` on any `LONG` column; an inferred
  `QTY` is just never a String, so no row matches. An `ENUM`'s getter in a
  compilation error's text needs the declared type too.
- `FIELD.equals(1.50)` on a `BIGDECIMAL` compares the values; GSF's
  `BigDecimal.equals` also compares the scale, which depends on its database.
- GSF's `Expr.java` (v8.15.29) caches `singlePatternLocalDate` and
  `stringLocalDate` in one `STRING_LOCAL_DATE_CACHE`, so for up to 60 s after
  `stringLocalDate('2024-01-01')` ran, a DateTime's `dateIsEqual(F,
  '2024-01-01')` gets the cached day instead of throwing. Not modelled: each
  call parses as its own overload does.
- A `CRITERIA_MATCH` naming a field the query doesn't expose is `LOGON_NACK
  INVALID_CRITERIA`, as on GSF — when the query's fields are known (declared, or a `fields`
  block). An inferred query can't tell, and evaluates the criteria on the stored row.
  (`REQ_` answers an unknown field with `REPLY: []`, as GSF does — see
  [Request servers](#request-servers-req_).)
- `RECORD_ID`/`TIMESTAMP` are safe integers, not GSF's ~7.5e18 values (see
  [Record identity](#record-identity-row_ref-and-sequences)).

- The opt-in auth gate answers with typed `NOT_LOGGED_ON` replies, not the
  router's `MSG_NACK` 401.

## Tests

`pnpm --filter @genesislcap/mock-server test` runs the unit tests for
the criteria grammar, store, metadata inference, and event-table inference
plus end-to-end WS/HTTP tests (auth gating, filtered-subscription DELETE
delivery, keyed `REQ_*` lookups, session reuse, clean shutdown — and, in
`test/dataserver.test.ts`, the GSF envelopes, paging and the `'legacy'`
fidelity mode; `test/requestReply.test.ts` covers both kinds of request server,
`test/views.test.ts` the view join model and its live pushes,
`test/commitSemantics.test.ts` when an event commits)
via `node --test`, no build step. Only `test/**/*.test.ts`
files are run, so shared helpers (`test/support.ts`) can sit beside them.

## What's per-project vs. generic

Per-project (goes in your own config, e.g. `packages/showcase/client-app/mock-server`):
table/view schemas, seed data, event-handler names and validation rules, auth
users (or the [platform preset](#platform-preset)'s, seeded with the app's own).

Generic (lives here): message routing, `SOURCE_REF` handling, criteria
evaluation, metadata derivation, subscription/broadcast plumbing. If you're
building a mock server for a different Genesis client app, depend on this
package and write your own config — don't fork the engine.
