# 16 — Gotchas

Cross-cutting traps that produce **silent** bugs — a service that builds, passes an obvious test, and is
wrong on the wire. Each one below has shipped at least once. This document is deliberately short; read it
in full.

## An empty string is a value, not an absence

Request bodies are usually built by dropping unset params. Drop only `undefined` and `null`:

```js
const prune = obj => Object.fromEntries(
  Object.entries(obj).filter(([, v]) => v !== undefined && v !== null)
)
```

Filtering on falsiness instead — `.filter(([, v]) => v)` or `if (!value) return` — also drops `''`, `0` and
`false`, and each of those is a legitimate value some API expects. The common casualty is **clearing a
field**: many APIs clear a description, a caption or a title by receiving an explicit empty string, so a body
builder that prunes `''` makes the documented "leave it blank to clear it" behaviour unreachable, with no
error anywhere.

**The inverse is equally sharp, and appears the moment you fix the first half.** Once `''` reaches the wire,
every optional string param sends one when the flow author leaves it blank — and for an API where an absent
field means *keep the current value*, a blank one now **overwrites** it. Copy/duplicate endpoints are the
usual place this bites: an absent caption keeps the original, an empty caption erases it.

So the rule is two-sided:

- Prune only `undefined` / `null` — never on falsiness.
- Then, per param, decide what blank *means*. Where blank means "don't send", convert it at the call site
  with `params.caption || undefined`, and say so in a comment. Where blank means "clear it", let it through.

There is no safe global default here; it is a per-parameter decision that has to come from the vendor's
documentation.

### The same `''` fails a format-constrained field

Because `''` is a value, it reaches the format check and fails it — and `.optional()` does not help, since
the field WAS provided. A cleared text box is what sends it, so this fires the first time a flow author
leaves an optional URL or email blank:

```js
webhookUrl: z.url().optional().label('Webhook URL')     // blank → «Webhook URL» is invalid — expects a URL
webhookUrl: z.union([z.url(), z.literal('')]).optional().label('Webhook URL')   // blank → not provided
```

Declare the union wherever blank has to mean "not set". A non-empty value still has to satisfy the format.

## Guard a lookup table keyed by user input

An object literal used as a lookup table inherits from `Object.prototype`, so a key like `constructor`,
`__proto__`, `toString` or `hasOwnProperty` resolves to something that is not your data:

```js
const MIME_BY_EXT = { png: 'image/png', pdf: 'application/pdf' }
MIME_BY_EXT['constructor']            // → function Object() { [native code] }
```

When the key comes from a filename, a param, or an API response, that value goes straight onto the wire — a
file named `notes.constructor` produced a real `Content-Type: function Object() { [native code] }` header.
`??` does not save you, because the inherited value is neither `undefined` nor `null`.

Keep the table a plain object literal and **guard the read**:

```js
const MIME_BY_EXT = {
  png: 'image/png',
  pdf: 'application/pdf',
}

const mime = Object.hasOwn(MIME_BY_EXT, ext) ? MIME_BY_EXT[ext] : 'application/octet-stream'
```

One guard at the point of the lookup is the whole fix. Do not reach for `Object.create(null)` or
`Object.assign(Object.create(null), { … })` to build the table — it makes every declaration in the file
harder to read in order to defend a single line, and the line is where the defence belongs.

The same applies to a computed *write* (`out[key] = value`): a `__proto__` key assigns a prototype rather
than an own property. That is only reachable if the keys come from data, so prefer building bodies from
literal names rather than spreading a params object or an API response into one.

## A dictionary's empty list must mean "nothing matched"

[05-dictionaries.md](05-dictionaries.md) is explicit that **unresolvable input** returns
`{ items: [], cursor: null }` rather than throwing. It is easy to over-apply that into a bare `try/catch`
around the whole handler, which then reports a 401, a 403 or a dropped connection as an empty dropdown. The
flow author sees a list with nothing in it and no way to learn that their credentials are wrong.

Only the *input* half is an empty list. Split it on what the API is telling you:

- **The value does not exist or is not visible** — typically `400`/`404`, e.g. `chat not found`,
  `user not found`, `invalid file_id` — return the empty list.
- **The request could not be made** — `401`, `403`, `429`, `5xx`, transport failure — let the normalized
  error propagate.

A **browse** path deserves more suspicion than a resolve path: where the endpoint always returns at least one
row, an empty list has no legitimate reading at all, so catching there hides every failure and helps nothing.

## Normalize once, at the point of derivation

`initContext` routinely cleans a config value — trimming a pasted token, coercing a numeric string, defaulting
a blank. The cleaned copy then lives in `context` **alongside** the raw one in `config`, and both are in every
handler's bag. Derive the same thing from each and you get two different answers:

```js
initContext: ({ config }) => ({ token: String(config.botToken ?? '').trim() }),

// one side signs with the trimmed token…
subscribe    : ({ context })       => register({ secret: secretOf(context.token) }),
// …the other verifies with the raw one
verifyRequest: ({ config, rawBody }) => check(rawBody, secretOf(config.botToken)),
```

With a clean value the two agree and every test passes. With a token pasted carrying a trailing space they
diverge, and the failure is **silent and total**: the webhook registers fine, every delivery is rejected, and
nothing is logged anywhere. It surfaces as "the trigger just never fires", in one workspace, after deploy.

The fix is not to pick the same field at both call sites — a later edit re-introduces the split. **Normalize
inside the shared derivation**, so no caller can get it wrong:

```js
function secretOf(token) {
  return hmac(String(token ?? '').trim(), …)     // both call sites now collapse to one string
}
```

The general rule: when one value is reachable in more than one form, either derive from it in exactly one
place, or make the derivation itself idempotent to the difference. This applies to anything `initContext`
touches — a base URL with or without a trailing slash, an id as a string or a number.

## A schema slot must be a `z.object`, or the method quietly has no inputs

`params`, `criteria` and `configItems` are containers of named fields. Hand one anything else and there is
nothing to iterate:

```js
params: { chatId: z.string() }     // ✗ a plain object — no z.object(...) around it
params: z.string()                 // ✗ a single field where a container belongs
```

Both used to sail through. The method published **no** params, the node rendered with no inputs, validation
became a passthrough, and the service deployed clean — the failure surfaced later, as an action that could
not be called correctly. The runtime now rejects it at registration with `FR_EXT_INVALID_SCHEMA`, naming the
item and the slot, so `flowrunner deploy` stops instead.

The trap that survives the guard is the *shape* of optionality:

```js
params: z.object({ chatId: z.string() }).optional()   // ✗ the CONTAINER cannot be optional
params: z.object({ chatId: z.string().optional() })   // ✓ the FIELDS are optional
```

A wrapped container has no `.shape`, so every field inside it would be invisible. If the method takes no
input at all, omit `params` entirely, or declare `z.object({})` — see [03-parameters.md](03-parameters.md).

## An inbound delivery is answered once — a throw is a 500

`handleTriggerResolveEvents` is **not retried**. Every other trigger request type — `subscribe`,
`refresh`, `unsubscribe`, `selectMatched`, polling — is attempted up to 5 times; this one is attempted once,
because a retry is not a recovery here. The usual failure on this path is the service *rejecting* the
payload, which is never transient, and retrying re-ran the handler five times over a delivery it had just
declined.

Two consequences for anything you write in `handshake`, `resolveEvents` or `verifyRequest`:

- **Never reject by throwing.** A throw answers the provider `500`, which fails the vendor's endpoint
  validation and, for a forged delivery, hides a rejection behind what looks like an outage. Return
  `responseToWebhook` instead ([09-realtime-triggers.md](09-realtime-triggers.md#responsetowebhook--answering-the-provider)).
- **A transient error is yours to absorb.** If a call inside this seam can fail and succeed on a second
  attempt, retry it in the handler — nothing upstream will.

## Do not hedge against an unclear runtime contract

When a runtime behaviour is not documented in [13-runtime-contracts.md](13-runtime-contracts.md), the
tempting move is defensive code that tries to work either way — feature-detecting a method, tolerating two
shapes. **That hedge is how a silent bug gets written**: it looks careful, it type-checks, and it picks the
wrong branch without complaining.

Establish the fact instead. A scratch script against `flowrunner-cli/runtime` settles most runtime questions
in a minute (see [13-runtime-contracts.md](13-runtime-contracts.md) for the pattern and the reload caveat),
and a local `http` server settles any question about what actually goes out on the wire. Then write the one
branch that is true — and add the fact to 13, which is the fix that stops the next service repeating it.

## Where a fact already lives

Do not restate these here; they are covered where they belong.

| Trap | Document |
|---|---|
| A dependency installed at the **project root** is not deployed — install it in `services/{id}/` | [01-service-structure.md](01-service-structure.md) |
| `.form(data)` only stages a body; `.send()` after it issues an empty request | [04-http-requests.md](04-http-requests.md) |
| A `Blob` appended straight to `Request.FormData` throws `source.on is not a function` — pass it to `.form()` | [04-http-requests.md](04-http-requests.md) |
| A binary download is `.toBuffer()`, not a response encoding — an encoded body is parsed and corrupted | [04-http-requests.md](04-http-requests.md) |
| `ResponseError.message` is the response *body*, so it can be an object | [04-http-requests.md](04-http-requests.md) |
| A blank (`''`) fails `z.url().optional()` — `.optional()` does not cover it | this document |
| `.default()` is not applied to realtime-trigger `match` params | [13-runtime-contracts.md](13-runtime-contracts.md) |
| `rawBody` is **absent**, not empty, when there is nothing to verify — hashing `''` looks like a bad signature | [09-realtime-triggers.md](09-realtime-triggers.md) |
| `FLOW`/`EXECUTION` file scopes fail in a method that runs outside a flow execution | [10-files-api.md](10-files-api.md) |
| Unresolvable dictionary *input* is an empty list, not an error | [05-dictionaries.md](05-dictionaries.md) |
| `dynamicParams.resolve()` must return a `z.object` — checked when the loader runs, not at load | [03-parameters.md](03-parameters.md) |
