# 13 — Runtime Contracts

The other documents describe what to declare. This one collects the handful of **runtime** facts that change
what you write and that are otherwise visible only by reading the CLI bundle.

> **Do not read the CLI bundle** (`flowrunner-cli/runtime/index.js`, its `.d.ts` tree, or the templates) to
> answer a question about authoring, and do not reason from what the runtime "probably" does. This document
> is meant to be complete: if a fact you needed is missing, that is a defect here. Report it and proceed on
> what *is* documented, saying which part you could not confirm — do not go and read the source, and do not
> fill the gap with an assumption.

## The `Flowrunner` global vs the `flowrunner-cli/runtime` module

Two different surfaces, easily confused:

| Surface | How you get it | Contains |
|---|---|---|
| **`Flowrunner` global** | always present when the platform runs your service; a harness gets it as a side effect of importing `flowrunner-cli/runtime` | `Request`, `z`, `crypto`, `createExtension`, `getExtension`, `ServerCode` |
| **`require('flowrunner-cli/runtime')`** | explicit import — **harness-side only** | `buildServiceDefinition`, `getServiceConfigs`, `runServiceMethod`, `detectVersion`, `resetRegistry`, `parseJsDocToServiceJson` |

A service module uses only the global, and `require`s nothing. Note that importing the module is what
installs the global — there is no separate namespace export to pull in.

## Verifying that a service builds

`src/index.js` is only ever loaded by the runtime, so a typo in a schema surfaces as a build failure rather
than a syntax error. Check it directly:

| Export | Signature | Returns |
|---|---|---|
| `buildServiceDefinition` | `(serviceId, serviceEntryPointPath)` | `SharedExtensionDefinition` — `serviceInfo` + `methodsMap` (see [11-output-model.md](11-output-model.md)) |
| `getServiceConfigs` | `(serviceId, serviceEntryPointPath)` | `ConfigItem[]` — the published config descriptions |
| `runServiceMethod` | `(data: RunServiceMethodData)` | `Promise<unknown>` — invokes one method, SYSTEM methods included |
| `detectVersion` | `(serviceId, serviceEntryPointPath)` | `'v1' \| 'v2'` |
| `resetRegistry` | `()` | clears the extension registry — **not sufficient on its own**, see below |

```js
// throwaway check — run it, read it, delete it
const path = require('path')
const { buildServiceDefinition } = require('flowrunner-cli/runtime')

const model = buildServiceDefinition('telegram', path.resolve('services/telegram/src/index.js'))
console.log(Object.keys(model.methodsMap))
```

**Put the script in the project root.** `require('flowrunner-cli/runtime')` resolves relative to the
script's own location, so the same file fails with `MODULE_NOT_FOUND` from `/tmp` and works from the root.
If the project's `package.json` sets `"type": "module"`, name it `.cjs`.

`RunServiceMethodData` — for exercising a single method:

| Field | Req | Notes |
|---|---|---|
| `serviceId`, `serviceEntryPointPath` | ✓ | as above |
| `methodName` | ✓ | action/trigger `id`, or a SYSTEM method from [08](08-polling-triggers.md)/[09](09-realtime-triggers.md) |
| `methodData` | – | `Record<string, unknown>`|
| `configs` | – | your `configItems` values — ONE bag. `.shared()` names the dialog that fills a value in, not how it is passed; a host running a shared extension merges its global and per-app tiers before calling in |
| `executionContext` | ✓ | `{ runtimeKey, accessToken, appId, apiKey, serverURL, flowId, executionId }` |
| `retrieveNewS3Token` | ✓ | `(runtimeKey) => Promise<{ filesS3Token }>` |
| `createFiles` | – | supplies `ctx.files` — see [10-files-api.md](10-files-api.md) |
| `logger` | – | defaults to the runtime logger |

**This is a check, not a test suite.** A scratch script that you run and then delete is the expected end
state. The project's suites are a separate thing with their own harness — `sandbox/` and
`services/{id}/tests/`, written by `flowrunner init` — and they wrap these same exports with an HTTP mock
and a Files backend. See [15-unit-tests.md](15-unit-tests.md); do not build a private harness alongside it.

### Reloading the same service in one process

`buildServiceDefinition` can be called repeatedly without any reset — the second call returns the same built
model. You only need `resetRegistry()` when the module has *changed* on disk and you want it re-read, and
then it is **half the job**: the module body is what calls `createExtension`, and Node runs a module body
only on first `require`. Clearing the registry without clearing the require cache leaves the runtime with no
registered service and the next call fails on

```
Cannot read properties of null (reading 'methodsMap')
```

which reads like a broken service rather than a stale cache. Do both, or simplest of all, use one fresh
process per check:

```js
resetRegistry()
delete require.cache[ENTRY]          // ENTRY must be the same resolved absolute path
buildServiceDefinition('telegram', ENTRY)
```

## Exercising SYSTEM methods

`runServiceMethod` drives the `handleTrigger*` methods, but their payload does not sit directly in
`methodData` — it goes one level down, under `invocation`. Omitting the wrapper fails with
`missing required "invocation" in method input`.

| `methodName` | `methodData` | Returns |
|---|---|---|
| `handleTriggerUpsertWebhook` | `{ invocation: { callbackUrl, triggers, webhookData?, connectionId? } }` | `{ webhookData?, eventScopeId?, refreshIntervalInSeconds? }` |
| `handleTriggerDeleteWebhook` | `{ invocation: { callbackUrl, webhookData, connectionId? } }` | `{ webhookData? }` |
| `handleTriggerSelectMatched` | `{ invocation: { eventName, eventData, triggers } }` | `{ ids, enrichedEventData }` |
| `handleTriggerResolveEvents` | `{ invocation: { body, rawBody?, headers?, query?, connectionId? } }` | `{ events: [{ name, data }], responseToWebhook? }` (plus the deprecated `responseToExternalService`, still forwarded) |

`handleTriggerResolveEvents` is the inbound-delivery seam. **`body` is required** — omitting it fails with
`body: Invalid input: expected nonoptional, received undefined`. `rawBody` and `headers` are the only fields
forwarded to `verifyRequest`; `query` is accepted but not forwarded, and `connectionId` is echoed back on the
response. Unknown fields in `invocation` are ignored rather than rejected.

It answers `{ events: [] }` for a delivery no trigger claims, and `{ handshake: true, events: [] }` for an
empty body — the runtime acks those automatically, with no `handshake` declared
([09-realtime-triggers.md](09-realtime-triggers.md)). A `verify` / `verifyRequest` that turns a delivery down
**resolves** with `{ events: [], responseToWebhook: { httpStatus: 401, rawBody: '' } }` — it does not reject,
so assert on the returned value, not on a thrown error.

### What the harness cannot reach — do not probe for it

**`subscribe` always receives `triggers: []` when driven through `runServiceMethod`.** The live trigger set
is resolved from persisted flow instances, which a scratch script has none of; whatever array you put in the
invocation is discarded, silently and without a validation error. The same is true of the `triggers` entries
handed to `handleTriggerSelectMatched` — varying their shape changes nothing observable.

This is a limit of the local harness, not a bug in your service, and it is **not** something to reverse-engineer
by calling the method with deliberately malformed input to read the validation errors back. That technique
tells you about the runtime's input validator, not about what your `subscribe` will receive in production,
and conclusions drawn that way have been wrong.

Verify the parts you own by calling them directly instead — your `subscribe` body is an ordinary function:

```js
require('flowrunner-cli/runtime')                     // installs the Flowrunner global
require(ENTRY)                                        // registers the service
const ext = Flowrunner.getExtension('telegram')       // note: on the global, not the module export
// → { props, actions, dictionaries, pollingTriggers, realtimeTriggers, loaders, realtime }
//
// actions / dictionaries / pollingTriggers / realtimeTriggers / loaders are each a **Map keyed by id**,
// not an array: `.get('sendPhoto')` and `.values()` work, `.find()` is undefined. `realtime` is the
// object passed to setupRealtimeTriggers.

await ext.realtime.subscribe({                        // exactly the props you passed to setupRealtimeTriggers
  callbackUrl: 'https://cb/x',
  triggers: [{ id: 'i1', params: { chatId: '42' } }], // your set arrives intact on this path
  webhookData: null, config, apiRequest, logger,
})
```

Called this way the ctx bag is yours to supply, so stub `apiRequest` and assert on what `subscribe` would
have sent.

If a SYSTEM-method contract you needed is not in the table above, it belongs there — report the gap rather
than inferring it from error messages.

Driving these methods from a *suite* rather than a scratch script is the same call with the same limits —
[15-unit-tests.md](15-unit-tests.md) covers the invocation shapes and what to assert.

## Where `.default()` is applied

`.default(v)` is applied by the **invocation path**, not by the schema itself. Three paths differ:

| Path | `.default()` filled |
|---|---|
| Action `execute` params | ✓ |
| Dictionary `criteria` | ✓ |
| Realtime trigger `match` params — object **and** function form | ✗ |

An action's params are parsed, so `execute` sees defaults filled in. A trigger's `match` receives the
**persisted instance config verbatim**: no defaults.

`.labels()` needs no path at all — it is display metadata, so the value the element stored is already the
value the handler receives.

The consequence for `match`: normalize by hand, and treat an absent value as "no filter" rather than
assuming a default arrived.

```js
match: ({ params, providerEventData }) => {
  const want = params.status ?? 'open'          // .default('open') does NOT arrive here
  if (!params.chatId) return true               // absent → no filter
  return providerEventData.status === want
}
```

The declarative object form of `match` compares with string coercion and skips params that are `null` or
`''` — see [09-realtime-triggers.md](09-realtime-triggers.md).

## `Flowrunner.Request`

The runtime HTTP client, and the transport under `apiRequest`. Its full surface — the sending model,
methods, query, headers, bodies and multipart, response handling and the `ResponseError` shape — is
**[04-http-requests.md](04-http-requests.md)**; client certificates are
[17-client-certificates.md](17-client-certificates.md).

The one fact worth repeating here, because it is the source of a silent bug: **the request instance is
thenable, so `await` on a chain is what issues the request.** `.send()` and `.toBuffer()` are the exceptions
— both are terminal, firing immediately and returning a `Promise` rather than the request. `.form()` only
stages a body, so `request.form(data).send()` issues one request with no body at all.
