> Status: current
> Owns: the infrastructure layer of a business service — configuration resolution, Tier-1 validation, RabbitMQ, registration, monitoring, the connectors and the handler-dispatch pipeline

<!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
Uniform: [library/runtime](../conn-orch-validator/manifests/library.manifest.json)

Duty sections that apply:

- `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-PACK-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
- `runtime`: L-RUNTIME-CLIENT
<!-- END GENERATED: library-uniform -->

# @onlineapps/service-wrapper

**The infrastructure layer of a biz service.** A service brings handlers and
`config/service/`; the wrapper brings everything else — configuration
resolution, Tier-1 validation, RabbitMQ, registration with Registry, monitoring,
cache/state/secrets connectors, the v3 handler-dispatch pipeline, and the health
heartbeat.

> Architecture in depth:
> [ADR 0005 — no HTTP in biz containers](/docs/biz/80-decisions/0005-no-http-in-biz-containers.md)
> and [the canonical biz service shape](/docs/biz/00-model/service-shape.md)

## A biz container runs zero HTTP

There is no Express app, no port to bind, no `GET /health` and no `GET /info` —
they were retired by
[ADR 0005](/docs/biz/80-decisions/0005-no-http-in-biz-containers.md). Work
arrives over MQ and is dispatched **in-process** to a handler function; liveness
leaves over MQ as a heartbeat. `ServiceWrapper` still accepts `options.app` and
`options.server` from callers that have not stopped passing them, and reads
neither.

## Quick start

```javascript
// index.js of a biz service — the whole file
require('@onlineapps/service-wrapper').bootstrap(__dirname);
```

`bootstrap(serviceRoot)` loads `<serviceRoot>/src/config` — the service's configuration
has that one source, `service.env` included (`ConfigLoader.loadAll()` fills it from
`NODE_ENV`); a root without it, or a configuration without `service.env`, stops with a named
error, and `options.config` is refused by name rather than ignored — builds the wrapper
with `<serviceRoot>/src` as the handler base directory (no override: `options.serviceBaseDir`
is refused by name too), runs
`wrapper.initialize()` and installs SIGTERM/SIGINT shutdown. The database handle `db`
goes in its second argument, and so does the
scheduler boot hook `scheduler: { recurrences }`, which `bootstrap()` hands to the
wrapper unchanged (see [Telling the scheduler](#telling-the-scheduler-one-rail-and-one-start-order-fact)).
Any other shape of `scheduler` stops the boot with a named message.

A service that owns a database hands its Sequelize instance over there:

```javascript
// index.js of a biz service that owns a database
const sequelize = require('./src/db');   // built by createSequelize()
require('@onlineapps/service-wrapper').bootstrap(__dirname, { db: sequelize });
```

Constructing the wrapper directly is for tests and special cases:

```javascript
const { ServiceWrapper } = require('@onlineapps/service-wrapper');

const wrapper = new ServiceWrapper({
  config,                       // { service: {...}, wrapper: {...} }
  operations,                   // the operations.json object
  serviceRoot: __dirname,       // for Tier-1 validation
  serviceBaseDir: `${__dirname}/src`  // REQUIRED — handler refs resolve from here
});

await wrapper.initialize();
```

## The logger

**A service wires nothing.** `initialize()` hands `wrapper.logger` to the
package's shared proxy in FÁZE 0.12, the phase that builds it — so the logger is
live before FÁZE 0.2 runs the service's own Tier-1 cookbooks, and before every
phase after it. There are two ways to read it, and which one you use depends on
where you are, not on preference:

| Where | What to call |
|---|---|
| inside a handler | `ctx.logger` — the wrapper's logger as `ContextBuilder` hands it over: `.child({operation, workflow_id, step_id, correlation_id, tenant_id, workspace_id})` when the logger provides `child()`, the instance itself when it does not |
| anywhere else (boot code, a module-level service, a job) | `require('@onlineapps/service-wrapper').getLogger()`, **at call time** |

`getLogger()` must never be bound into a module-level constant: consumers are
`require`d long before any logger exists, so a captured value would capture the
empty state. Before FÁZE 0.12 it throws — there is no console substitute,
because a service that lost its real logger must not look healthy
(`architecture-principles.md` §3, §4).

**The phase book goes to both channels on purpose.** The `[FÁZE …]` lines, their
structured `PHASE_<status>` JSON records and the startup lines (`_logPhase`,
`_logSourceFailure`, `_logStartup`) are written to stdout AND to the logger —
not a fallback, and not an oversight: the boot must stay visible on a container
whose logger writes elsewhere, or not yet at all (0.1 and a failed 0.12 run
before the logger exists), because a boot that stops in silence is the defect
`automation-gates.md` §5 names. Everything else in this package logs through the
injected logger alone. `tests/unit/ServiceWrapper.consoleChannel.test.js` holds
both halves of that line (lead's decision d.1288, closing W45).

**Removed** (breaking — CHANGELOG `[Unreleased]`): both ways a service used to
hand bootstrap a setter.

- `options.setLogger` is **refused by name**; the boot stops and says which line
  to delete. It is not ignored: a caller that passes a function on purpose must
  not be left believing it wired something.
- `<serviceRoot>/src/lib/logger.js` is no longer looked up. Behaviour steered by
  a file's *name* is exactly what `architecture-principles.md` §9 (Configuration
  over Convention) and §8 (Explicit over Implicit) rule out, and a service's own
  copy of the proxy was a second rail for one concern
  (`change-discipline.md` § One rail per concern).

Both ran **after** `initialize()` returned, which is why they could not be kept:
anything logging during the boot — Tier-1 first of all — hit
`[Logger] Logger not initialized`. A service still carrying `src/lib/logger.js`
deletes it and repoints its consumers at `getLogger()` / `ctx.logger`.

## The database handle

**The wrapper does not open a database connection and does not look for one.**
The connection has one owner — `createSequelize()` from
`@onlineapps/conn-base-db` (`api/docs/biz/70-contracts/database-contract.md`
§10) — and the service builds it once, in the module its handlers import it
from. The wrapper is *handed* that instance, under the key `db`, and calls
`authenticate()` on it to state `components.db` in the heartbeat. Nothing else.

`db` is required exactly when `config/service/integration-contract.json`
declares `requiredConnectors.db: true`, and refused when it declares `false`.
Both mismatches stop the boot in FÁZE 0.15, named:

| Contract | Handle | Result |
|---|---|---|
| `db: true` | passed | `components.db` = the verdict of `authenticate()` |
| `db: true` | absent | boot stops: `[ServiceWrapper] Database handle is required - …` |
| `db: false` | passed | boot stops: `[ServiceWrapper] Database handle was injected but not declared - …` |
| `db: false` | absent | `components.db` = `'unknown'`; nothing is probed |

The handle itself is checked where it arrives, in the constructor: anything that
does not expose `authenticate()` is refused there rather than at the first
heartbeat.

**Removed** (breaking — CHANGELOG `[Unreleased]`): the wrapper used to
`require(<serviceRoot>/src/config/database)` and read `authenticate()` off
whatever that file exported. That is the same binding-by-file-name the logger
shed above, and here it contradicted the platform outright: row `X-DB-CONFIG` of
the service manifest **forbids** `src/config/database.js` (owner decision
`api/docs/governance/confirmations/biz-service-manifest.md` 009, severity
`deploy`). A service that obeyed the uniform therefore reported `db: 'unhealthy'`
and readiness `not_ready` for a database that was perfectly healthy — measured on
biz-meta, on dev, with every suite in this package green. `ctx.db` is unaffected:
it stays `null`, because DB access does not run through the invocation context
(§10 of the same contract).

## What `initialize()` does, in order

| Phase | What happens | Failure |
|---|---|---|
| 0.1 | Configuration (already resolved in the constructor) | permanent |
| 0.12 | **Monitoring — the logger**, and the shared proxy filled with it — before anything that can block | permanent: a service that cannot log does not run |
| 0.1b | **Operations-Handler Alignment** — the v3 handler pipeline: every `handler` reference resolved, every operation's input/output schema compiled, `timeout_ms` and `sensitive_input_fields` declarations checked, declarations and registrations cross-checked both ways; the phase writes its own verdict | permanent |
| 0.15 | Integration contract loaded — the connectors and env the service declares — and weighed against what the boot was handed (`db`) | permanent |
| 0.2 | **Tier-1 validation** via `@onlineapps/conn-orch-validator`, before MQ opens | read off the failure (d.594): a finding of a step that reads only files — structure, config, env contract, operations, manifest — is **permanent on the first attempt**, no backoff; a failed cookbook run or an I/O failure of the validator is transient and keeps the six-step budget |
| 0.3 | RabbitMQ connection | transient (retried) |
| 0.4 | Waits for the `infrastructure:health:all` gate | transient |
| 0.7 | Registration with Registry (queue `registry.register`), carrying the validation proof — on a discovery Redis connection the server has answered (d.1235) | transient, unless the broker REFUSED (`code: 'MQ_CONNECTION_FATAL'`) or the discovery Redis REFUSED the credential (`code: 'REDIS_CONNECTION_REFUSED'` — the constant `@onlineapps/redis-contract` owns, carried by `@onlineapps/service-common` `connectRedis`, d.1242/d.1356) — then permanent, like 0.3. The marker is read as a value, never the message text |
| 0.16 | **Cache connector** | the phase stops the boot either way, and the disposition is read off the failure: a Redis that does not answer is transient (d.535), a declared cache with no `wrapper.cache.url` is permanent — the next boot reads the same config (d.596) — and so is a Redis that answered and REFUSED the credential (`code: 'REDIS_CONNECTION_REFUSED'` — the `@onlineapps/redis-contract` constant `@onlineapps/conn-base-cache` throws with, d.1237/d.1333, read by the wrapper from d.1242 on). `SKIPPED` when the service declares no `wrapper.cache` |
| 0.17 | **State connector** (`wrapper.state.enabled`), on the Redis `wrapper.cache.url` names | the phase stops the boot either way, disposition read off the failure as in 0.16: a key refused by name (`state.host/port/password/db`, a missing `wrapper.cache.url`) is permanent, a Redis that does not answer is transient. `SKIPPED` when the service declares no `wrapper.state` |
| 0.18 | **Secrets connector** (`wrapper.secrets.enabled`) — the SecretBox projection on `REDIS_URL`, keys from `SECRETS_MASTER_KEYS` | the same: `secrets.url` / `secrets.masterKeys` refused by name and a missing `REDIS_URL` / `SECRETS_MASTER_KEYS` are permanent, a refused connection is transient. `SKIPPED` when the service declares no `wrapper.secrets` |
| 0.19 | **Invocation context & orchestrator**: `ContextBuilder`/`ErrorMapper` over the connector snapshot, then the `WorkflowOrchestrator` when there is an MQ client (listeners start only after the Registry certificate, 0.8/0.9) | the phase stops the boot either way; **permanent by default** — nothing in it talks to a dependency (the context, the error handler and the orchestrator are constructed over handles earlier phases opened), so only an error marked transient itself is retried. Never `SKIPPED` — the context is built on every boot; without MQ it is `PASSED` with the reason that the orchestrator was not prepared |
| 0.10 | Health heartbeat publisher starts; the first beat is emitted synchronously | permanent: a publisher that cannot start is a configuration fault nothing retries (d.513) |

A service that fails validation never registers: phase 0.2 throws before phase
0.7 is reached.

**Phase 0.2 does not retry a refusal that a retry cannot change.** The startup
loop keeps its budget — 30 s, 60 s, 120 s, 300 s, 600 s, 1800 s between six
attempts — but spends it only where another attempt can answer differently: step
5 runs the service's own cookbooks against handlers and the database, and the
validator's own I/O can fail. The other five steps decide from the files already
on disk, so their verdict is the same on every attempt; the boot ends on the
first one, and the log says why waiting would not help. Measured twice before
d.594: biz-converter `exit 1` after 18 min 53 s, and W591's `MISSING_COOKBOOKS`
at 18.5 minutes — under `restart: unless-stopped` that is a loop in which the
post-deploy gate waits for a service that can never come up.

The disposition travels ON the error (`bootDisposition`), the way phase 0.3 has
always read `MQ_CONNECTION_FATAL` off the error `@onlineapps/mq-client-core`
throws: only the layer that produced the failure knows whether asking again can
change the answer.

**A phase written FAILED ends the boot, and `bootstrap()` never calls the service
ready after one.** The three `✓` lines it prints are the only place the platform
says a biz service came up, and until d.532 they were printed whenever
`initialize()` resolved — which a boot with a failed phase can do. Measured on
biz-meta: `[FÁZE 0.7] Registry Registration - FAILED` for a 406 on
`biz-meta.registry`, then `✓ biz-meta ready`, then a crash loop on
`[StateConnector] Not connected`. The condition is now the phase book itself
(`wrapper.bootFailure`), and `bootstrap()` throws instead of announcing.

## Telling the scheduler: one rail, and one start-order fact

A service announces facts and registers its own recurrences through **one rail** —
`ctx.scheduler` inside a handler, and the optional boot hook
`new ServiceWrapper({ scheduler: { recurrences: async () => ({ recurrences: [...], failures: [...] }) } })`
(FÁZE 0.7b, after the registry and before the consumers). No service builds an adapter over `wrapper.mqClient`:
the request itself is built by `@onlineapps/mq-client-core`, so the scheduler contract has one
builder for every sender — the wrapper and the operator CLIs that never boot it alike.

`idempotencyKey` is the caller's, always. For a recurrence it IS the schedule id, so booting
again upserts it rather than queueing a second one.

**What the hook returns, and what one bad source costs** (`api/docs/governance/confirmations/scheduler.md` 020).
The hook answers `{ recurrences, failures }`: the records to register, and the sources the
service could not turn into records, each as `{ source, error: { code?, message, details? } }`.
Both keys are required; anything else — an array, a missing key, a failure without `source` or
`error.message` — is a broken hook and stops the boot. Two kinds of failure are told apart:

- **A source failure is isolated.** A source in `failures`, a record this phase refuses by
  shape, and a record the request builder refuses (`error.code ===
  scheduler.SCHEDULER_REQUEST_INVALID` from `@onlineapps/mq-client-core` — the code, never the
  wording) are not registered; every other record is. Each gets its own line at level `error`,
  `[FÁZE 0.7b] source <source> - FAILED: <message>`, with the structured copy
  `PHASE_SOURCE_FAILED`, and the phase ends **`PARTIAL`** with the counts —
  `[FÁZE 0.7b] Declared Recurrences - PARTIAL (<k> registered, <m> sources failed)`. The
  service is ready: `PARTIAL` never records a boot failure, and it never reads as `PASSED`.
  Nothing is substituted or retried for the failed source.
- **A service failure stops the boot**, as every failed phase does: no MQ client, the hook
  throwing or breaking its contract, a pinned `@onlineapps/mq-client-core` without the factory or
  the code, and a publish the broker refuses — the broker is shared by every source.

A source that fails at boot is not stopped in the scheduler: the projection is incremental, so
a registration from an earlier boot keeps running until a corrected row is registered again or a
`cancel` removes it. The heartbeat does not carry partiality; the log does.

**Start order, stated because the wrapper does not solve it.** A registration naming a cookbook
the scheduler has not projected yet ends on `scheduler.dlq` **without retry until the service
boots again** — the publication itself succeeds, so nothing here fails and nothing retries. The
cookbook projection therefore precedes the biz services, and a service boot is the
re-registration (`api/docs/governance/confirmations/scheduler.md` 010).

## When MQ is lost for good, the service stops

`@onlineapps/mq-client-core` retries a lost broker connection with exponential
backoff up to `RABBITMQ_MAX_RECONNECT_ATTEMPTS` times. When that budget is spent
it declares the connection **permanently lost**: it stops trying, answers
`isConnected() === false`, refuses to publish — and calls the `onFatal` handler
the wrapper registers in phase 0.3. It never ends the process itself; that
decision belongs here.

The wrapper's answer is always the same three steps, on one path:

1. log the reason with `code` and `attempts`;
2. tear down through the **same** `shutdown()` a SIGTERM uses — no second
   cleanup path;
3. `process.exit(1)`.

The exit code is non-zero on purpose: the restart policy boots the service
again, and boot re-verifies every dependency (validation, the infrastructure
gate, registration). A process that keeps running with a dead MQ client is a
zombie — reachable, green in a healthcheck, and unable to do any work.

The same three steps end the service on the other two runtime failures that make
it useless: the registry heartbeat failing `registry.maxHeartbeatFailures` times
in a row, and re-validation exhausting every attempt. One rail, one exit code,
one teardown — `shutdown()` deregisters from the Registry on the way out, so
nothing keeps routing work to a process that is leaving.

There is no `health:shutdown` listener, and no such event: this wrapper never
passes the `mq.criticalHealthShutdown*` keys and registers no listener (both
went on 2026-09-07), and `@onlineapps/mq-client-core` 6 retires the two keys
and the event on the transport side — a sustained critical health is reported
(`health:critical`, `healthCriticalCallback`) and decides nothing about the
process. Who ends the process is this wrapper, on the one rail above.

That opt-in check is a diagnostic, not a gate: it logs what `onFatal` cannot
see — closed channels, and consumers whose `consumerTag` is gone, i.e. a service
that is connected and silently receiving nothing. It is off by default because
it runs on the workflow hot path and asks the broker about every tracked queue.
It is switched by configuration, never by `DEBUG` or `NODE_ENV`.

### Shutting down

`shutdown()` is the only teardown, and SIGTERM, SIGINT, a failed boot phase and
every fatal runtime condition all take it. In order: stop the heartbeat, cancel
the wrapper's two consumers (`workflow.init`,
`{service}.workflow`) by the tag `consume()` returned, through the MQ client's
`cancelConsumer()`, disconnect the discovery Redis (never `quit()`, which waits on a
Redis that may not answer — d.1096), deregister from the Registry,
disconnect cache, state, secrets, MQ, then the registry's own MQ connection —
which is what ends the registry client's consumer — and **last of all, the
monitoring connector**. Every step tolerates a resource
that was never created, so a boot that failed at phase 0.3 tears down as cleanly
as a service that ran for a week.

**Why the telemetry transport goes last.** `wrapper.logger` IS
`wrapper.monitoring` (phase 0.12), so every line the teardown writes — up to and
including "ServiceWrapper shutdown complete" — goes through the object that step
closes. Closing it any earlier silences the record of the teardown itself. Its
own failure is reported and never propagated: it is the final step, there is
nothing left to abandon, and the caller exits the process next.

Until 2026-09-14 that step did not exist. `@onlineapps/monitoring-core` used to
close the transport from its own `SIGTERM`/`SIGINT` handlers; that library
stopped taking process handlers (a library does not own the process), and from
then until this batch nothing closed it at all. The process still ended —
`bootstrap()` calls `process.exit(0)` — but an exit is not a flush, and the last
log lines and spans went with it.

The one exception is a failure of phase 0.12: it builds the logger and runs
before any connector exists, so there is nothing to release and nothing to
release it through.

**The teardown has a deadline.** `shutdown()` arms a watchdog before its first
step, for `BIZ_SHUTDOWN_TIMEOUT_MS` milliseconds — a platform env key
(`config/env-active/shared.env`, declared in `api/config/shared-env.json`), required
by the constructor, no default. A teardown that outlives it — a Redis or a broker
that holds the connection and never answers — ends with ONE line written
synchronously to stderr (not through the telemetry transport, which may be the
step that hangs) and exit code `1`:

```
[ServiceWrapper] Shutdown did not finish within <N> ms - pending step '<step>'; released: <steps>; not reached: <steps> - Fix: …
```

(a `failed: <steps>;` segment joins it when a step before the pending one
rejected). Because the watchdog lives in `shutdown()` itself, it covers every
caller: the signal handler, a failed boot phase (`_handleInitializationError()`)
and the fatal runtime path (`_shutdownFatally()`), over which nothing else would
ever send a SIGKILL. Owner's decision:
`api/docs/governance/confirmations/biz-shutdown-deadline.md` 001
— which also binds the value to the container: every biz compose declares a
`stop_grace_period`, and `BIZ_SHUTDOWN_TIMEOUT_MS` plus a reserve stays below it.

**Exit codes of the signal handler `bootstrap()` installs.** `0` only after a
clean teardown; `1` when `shutdown()` rejects (a step failed — its summary is
logged first) or when the deadline ends it. A second SIGTERM/SIGINT while the
teardown runs is ignored with one line; it never starts a second `shutdown()`.

### Two retry budgets, deliberately

| | Boot: waiting for a broker that is not up **yet** | Runtime: a connection that was **lost** |
|---|---|---|
| Owner | this wrapper, phase 0.3 | `@onlineapps/mq-client-core` |
| Configured by | `wrapper.mq.connectionRetries` / `connectionRetryDelayMs` / `connectionRetryMaxDelayMs` | `RABBITMQ_MAX_RECONNECT_ATTEMPTS` |
| Exhausted → | `_handleInitializationError('0.3', …)` — boot fails, exit 1 | `onFatal` — the three steps above, exit 1 |

They are not two implementations of one thing: nothing is lost during boot, and
nothing is being waited for at runtime. Both end in a non-zero exit, which is
the only part they share.

A wrapper cannot supervise a client that has no `onFatal`, so phase 0.3 refuses
to boot against one rather than discovering it later:

```
[ServiceWrapper] MQ client cannot report a permanently lost connection - onFatal(callback)
is missing from @onlineapps/conn-infra-mq. Expected: … Fix: install a
@onlineapps/mq-client-core that provides onFatal(callback).
```

The heartbeat's `components.mq` is `'healthy'`, `'unhealthy'`, or `'unknown'`
when no MQ client has been built yet, always from one owner (`_mqHealth()`).
It answers TWO questions with that one value, because losing either means the
service cannot take work: the connection (`isConnected()`) and the consumers
(`getConsumerState()` — `tracked` is what the client would restore, `active`
names what is attached right now). A client that is connected while some
consumer it registered is not attached reports `'unhealthy'`, and the queue that
lost its consumer is named by the MQ client's own ERROR record, not copied into
the heartbeat.
`components.cache` is the same arrangement: the value the periodic dependency
refresh left behind. That map is the service's ONLY statement of its own health
— ADR 0005 retired the HTTP `/status` this package used to answer alongside it —
and it names no registry: the registry client exposes nothing to ask, and a key
that can only say a constructor ran is not a health verdict.

## Service shape on disk

```
my-service/
├── config/service/
│   ├── config.json           # service + wrapper configuration
│   ├── operations.json       # v3 operations (handler refs, schemas)
│   └── integration-contract.json   # required connectors + env declaration
├── src/
│   ├── config/               # what bootstrap() requires
│   └── handlers/             # v3 handler modules
├── tests/
├── index.js
└── package.json
```

`config/service/` is the only configuration path the platform reads.

## Configuration

### Placeholders — `${VAR}`, never `${VAR:default}`

The wrapper resolves `${VAR}` anywhere in a string value of `config.json`
against `process.env`. An unset variable is a fail-fast error naming the
variable and the placeholder it came from.

**A default inside a placeholder is refused**, with the error
`[ConfigLoader] Unsupported placeholder default - "${PORT:3000}" is not allowed.
Fix: set ENV "PORT" or set an explicit value in config.json`. There is one value
for one key: either the environment sets it, or `config.json` states it
explicitly. (`src/ConfigLoader.js` — `resolveEnvPlaceholdersInString`.)

### `config.json`

```json
{
  "service": {
    "name": "biz-hello",
    "version": "${npm_package_version}",
    "description": "Simple greeting service",
    "specificationEndpoint": "/specification",
    "workspaceScoped": false
  },
  "wrapper": {
    "mq": { "url": "${RABBITMQ_URL}", "prefetch": 10 },
    "registry": { "enabled": true },
    "cache": { "enabled": true, "url": "${REDIS_URL}", "ttl": 300 },
    "state": { "enabled": true }
  }
}
```

`workspaceScoped` propagates verbatim into the Redis service spec, so meta-reader
and the Admin UI can tell workspace-aware services apart without an allowlist.

#### `wrapper.registry`

Two keys, and neither is an address or a cadence.

- There is no `registry.url`. Registration travels over MQ to `registry.register`
  and discovery reads the registry's Redis projection, so a biz service holds no
  registry address at all (owner confirmation
  [`biz-discovery-redis`](/docs/governance/confirmations/biz-discovery-redis.md) 002).
- There is no `registry.heartbeatInterval` either, and a config still carrying it
  stops the boot naming the key. **The cadence is the platform env value
  `BIZ_HEARTBEAT_INTERVAL_MS`** (`config/env-active/shared.env`), read once by the
  `ServiceWrapper` constructor (d.1130) and handed in phase 0.7 to both the registry
  client and the wrapper's own beat loop. It
  is shared because the process that publishes the beat and the process that
  judges its freshness are different ones: the registry multiplies the same key
  by `BIZ_HEARTBEAT_STALE_FACTOR` to decide when a silent service is stale, so a
  cadence written per service would reach one side only (owner confirmation
  [`biz-health-freshness`](/docs/governance/confirmations/biz-health-freshness.md) 001
  point 3). A wrapper without the key, or with a value that is not a positive number,
  is refused by the constructor naming it.
- Discovery has a deadline, and it is a platform env value too:
  **`REGISTRY_DISCOVERY_TIMEOUT_MS`** (`config/env-active/shared.env`, declared in
  `/config/shared-env.json`), read once by the `ServiceWrapper` constructor (d.1130) and
  handed in phase 0.7 to the registry client as `discoveryTimeoutMs`. The discovery Redis client is built with
  `disableOfflineQueue: true`, so a lookup while Redis is gone is refused at once and a
  lookup a half-open Redis never answers ends at the deadline — both typed `TRANSIENT`, so
  the step is retried rather than hanging until its message expires (d.1093, owner
  confirmation [`biz-discovery-redis`](/docs/governance/confirmations/biz-discovery-redis.md) 003).
  The same value is the ceiling of the connection itself: phase 0.7 builds that client with
  `connectRedis` of `@onlineapps/service-common` — `connect()` plus one verifying command, never
  a bare socket — so a `REDIS_URL` the server refuses (`NOAUTH`, `WRONGPASS`) fails the phase at
  once with the server's verdict and the `Fix:` pointing at the credential, and a Redis that
  never answers fails it at the deadline instead of hanging the boot (d.1235). A wrapper
  without the key, or with a value that is not a positive number, is refused by the
  constructor naming it.
- **Which platform keys the constructor reads**: `BIZ_SHUTDOWN_TIMEOUT_MS` (d.1105),
  `REGISTRY_DISCOVERY_TIMEOUT_MS` and `BIZ_HEARTBEAT_INTERVAL_MS` (d.1130). The last two
  are used only in phase 0.7, but read at construction: inside the phase, a missing key
  landed in its catch, which reports every error carrying no disposition marker as
  transient (which markers the phase reads is the 0.7 row of the phase table in
  § What `initialize()` does, in order, stated once) — a restart loop over a config no
  restart can fix. At construction the service is refused before it opens anything.
- `registry.enabled` gates the phase; `registry.maxHeartbeatFailures` is how many
  consecutive failed beats stop the service (§ Fatal runtime conditions). It ships
  in this package's `config/runtime-defaults.json`, which ConfigLoader merges
  under every service config — a service overrides it by naming it, and the
  wrapper invents nothing: a config that reaches the beat loop without the key,
  or with a value that is not a positive whole number of beats (`0` included, the
  value the old `|| 5` silently discarded), fails fast naming the key.

There is no `wrapper.timeout`, and a config still carrying it stops the boot
naming the key: it fed the orchestrator's `defaultTimeout`, which the
orchestrator no longer reads. The deadline of an attempt is the operation's
`timeout_ms` (see below).

#### `wrapper.errorHandling`

Two optional keys, forwarded to `@onlineapps/conn-infra-error-handler` exactly as
written and **never defaulted here**:

| Key | Meaning |
|---|---|
| `circuitBreakerEnabled` | run the per-dependency circuit breakers |
| `compensationEnabled` | run the registered compensation handlers |

A key the service does not write is not forwarded at all, so the default is the
one `@onlineapps/error-handler-core` declares and documents (`ErrorHandlerCore`).
The values are deliberately **not** repeated here: one fact, one owner — a second
copy would diverge the day the library moves one. A key outside the two is not
forwarded either; it configures nothing in the core, and a silent pass-through
would hide the misspelling.

There are no `errorHandling.maxRetries`, `errorHandling.retryDelay` or
`errorHandling.retryMultiplier`, and a config still carrying any of them stops
the boot naming each key. **A step is retried by
`@onlineapps/conn-orch-orchestrator`** under the retry policy of its operation:
the operation declares `retry` in `operations.json` (see below), and one that
declares none gets the orchestrator's platform default. The three keys
configured `RetryHandler` of `@onlineapps/error-handler-core`, which nothing on
this package's path calls — owner confirmation
[`step-error-classification`](/docs/governance/confirmations/step-error-classification.md)
002 and 004.

There is no `errorHandling.dlqEnabled`, and a config still carrying it stops the
boot naming the key. **Dead-lettering is the consumer policy of
`@onlineapps/mq-client-core`**: it counts the attempts in the message header,
`nack(requeue=false)`s an exhausted message to `<service>.dlq` and emits the one
`message_dlq` event. The switch had exactly one reader, `DLQRouter` inside
`@onlineapps/error-handler-core`, and that second rail went with it — see that
package's `CHANGELOG.md`, section `Removed — DLQRouter`, and owner confirmation
[`mq-consumer-contract`](/docs/governance/confirmations/mq-consumer-contract.md)
002 point 3.

### `operations.json` (v3)

```json
{
  "schema_version": "3.0",
  "operations": {
    "good-day": {
      "description": "Generate a good day greeting",
      "handler": "handlers/v3/greetings#goodDay",
      "mutates": false,
      "bundle_scope": "platform",
      "minRole": "EDITOR",
      "input":  { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } },
      "output": { "type": "object", "required": ["message"], "properties": { "message": { "type": "string" } } }
    }
  }
}
```

`handler` is `path/to/module#exportName`, resolved from `serviceBaseDir`. The
retired v2 fields — `endpoint`, `method`, `path` — are rejected by validation.
Full rules: [schema-v3.md](/docs/biz/30-operations/schema-v3.md).

Five fields of a record are read by the wrapper on the orchestrator's behalf and
handed back verbatim for the step being dispatched: `mutates`, `resource_type`,
`sensitive_input_fields`, `retry` and `timeout_ms`. `sensitive_input_fields` keeps a step whose
input carries a secret out of the read-cache and tells the orchestrator which
consumed values to scrub afterwards, so an operation that declares one and a
record that omits it are two different answers — the field is never invented as
an empty list. `retry` (`{ "max_attempts": N, "delays_ms": [...] }`) is the
operation's own step-retry policy; the wrapper does not validate it — the
orchestrator does, and refuses a malformed one by name — and an operation that
declares none answers `undefined`, so the orchestrator's platform default applies.

`timeout_ms` is the deadline of ONE attempt of the operation, and this wrapper
enforces it: an integer from 1 000 to 300 000 ms, or absent for the platform
default of 60 000 ms. These three numbers are `@onlineapps/handler-contract` 1.1.0
`stepLimits` (`STEP_TIMEOUT_MIN_MS`, `STEP_TIMEOUT_MAX_MS`,
`DEFAULT_STEP_TIMEOUT_MS`), the one owner of the step limits; this README only
restates them. They are not the wrapper's own either: `src/stepDeadline.js` reads
them from that package and keeps no copy (conf `handler-contract` 002, d.1382).
A declaration outside that range stops the boot in FÁZE
0.1b, naming the operation. `invokeOperation()` races the handler against the
deadline; when it runs out, `ctx.abortSignal` aborts (with the timeout as its
`reason`) and the attempt answers 504 `STEP_TIMEOUT`, type `TIMEOUT`, with
`details: { timeout_ms, operation }`. Whether the step is tried again is the
orchestrator's decision. A handler that keeps running after the deadline has
its outcome discarded, with one `warn` line saying so — so a handler doing
long work listens to `ctx.abortSignal`. Owner confirmation
[`step-error-classification`](/docs/governance/confirmations/step-error-classification.md) 005.

`sensitive_input_fields`, when declared, must be an array of unique non-empty
field names (`[]` included); any other shape stops the boot in FÁZE 0.1b,
naming the operation — the redacted-input guard and the recipe rule the
orchestrator builds both read it, and a declaration neither can apply must not
wait for the first dispatch to be found. The registration contract
(`@onlineapps/service-validator-core`) does not check this key yet; this is the
wrapper's own minimal check until it does.

## Invocation model

A message on `{serviceName}.workflow` (durable, manual ack, prefetch from
`wrapper.mq.prefetch`) becomes a direct call:

```javascript
// src/handlers/v3/greetings.js
exports.goodDay = async (input, ctx) => ({
  message: `Good day, ${input.name}!`,
  timestamp: new Date().toISOString()
});
```

`ctx` is frozen at the top level and carries 17 fields:

| Group | Fields |
|---|---|
| Row-level scoping (from the MQ envelope) | `tenant_id`, `workspace_id`, `person_id` |
| Correlation / identity | `operation_name`, `workflow_id`, `correlation_id`, `step_id` |
| Infrastructure handles | `logger`, `db`, `cache`, `httpClient`, `secrets`, `state`, `scheduler`, `stream`, `abortSignal`, `config` |

Contract: [handler-dispatch.md](/docs/biz/10-invocation/handler-dispatch.md),
tenancy: [tenant-context.md](/docs/biz/20-tenancy/tenant-context.md).

**`step_id` is the step, not the operation.** A handler that stores a file
passes `context: { workflow_id: ctx.workflow_id, step_id: ctx.step_id }` to
`@onlineapps/content-resolver`, which keys every stored object on
`content/<workflow_id>/<step_id>` and refuses to invent either half. Using
`ctx.operation_name` in its place collides whenever one cookbook calls the same
operation twice — two steps, one prefix, the second overwriting the first.
`WorkflowOrchestrator` sends the step id with every invocation; the wrapper
refuses an invocation that arrives without one.

**`input` is a required object.** An operation message without one — absent,
`null`, a string, an array — is refused with `InvalidEnvelopeError` (400
`INVALID_ENVELOPE`) and the handler is not called; an operation that takes
nothing receives the `{}` its cookbook step declares. Nothing between the recipe
and the handler supplies a missing input — owner confirmation
[`cookbook-step-input`](/docs/governance/confirmations/cookbook-step-input.md) 001.

**A redacted copy of the input never reaches the handler.** `redactSensitiveDeep`
of `@onlineapps/logger-contract` replaces every declared sensitive field with
`REDACTED_PLACEHOLDER` (`[REDACTED]`) in the copies made for logs, traces and
events ([secretbox.md](/docs/architecture/secretbox.md) §8). The execution
message must never be one of those copies, so `invokeOperation()` guards it: a
declared sensitive field whose value is the placeholder — matched by key name
at any depth, the way `redactSensitiveDeep` matches — refuses the message with
`InvalidEnvelopeError` (400 `INVALID_ENVELOPE`) before the handler runs; the
refusal names the field and never a value. The wrapper redacts nothing itself.
The rule it applies is the recipe's: `sensitive_input_fields` on the
orchestrator's message, the sorted unique union of every operation's
declaration. A message without the key is a direct dispatch, which has no
recipe, so its rule is the operation's own `operations.json` declaration
(0.2b-1053, N1); a key present in any shape other than an array of non-empty
strings is refused by name. A placeholder in a field nobody declared passes —
the guard applies a rule, it does not guess one.

**Schemas are enforced on both sides.** Every operation's input and output
schema is compiled during `initialize()` — a broken schema fails the boot, not
the first message. A mismatched input is a `ValidationError` with
`phase: 'input'` (400, caller's bug); a mismatched output is the same error with
`phase: 'output'` (500, this service's bug). Nothing is coerced: a
number-shaped string is an error.

**Errors are thrown, not returned.** A handler throws one of the business error
classes and the wrapper maps it to the response envelope — `ValidationError`
(400), `NotFoundError` (404), `ConflictError` (409), `BusinessRuleError` (422),
`AuthorizationError` (403), plus `InvalidEnvelopeError`, `UnknownOperationError`
and `AbortError` raised by the wrapper itself. The contract is a brand plus a
shape, not a class identity, so a service may declare its own class:
[error-handling.md](/docs/biz/70-contracts/error-handling.md).

### The caller's envelope without the log — `toCallerEnvelope(err, context)`

A handler normally throws and is done: the wrapper maps the throw and writes the
one log line that failure deserves. Some services also **handle the failure
themselves** — they store what the caller was told, or answer a later poll with
it — and for that they need the envelope as a value, at a point where they have
already logged the failure. Building it through `new ErrorMapper({ logger }).map()`
logs it a second time, so one 5xx appears twice in the log.

`toCallerEnvelope` is that same mapping as a pure function: no logger argument,
no log line, no other side effect. Same table, same masking, same
`correlation_id` — it and `map()` share one implementation, so a status and its
log line cannot drift apart.

```javascript
const { toCallerEnvelope } = require('@onlineapps/service-wrapper/errors');

try {
  await convert(job, ctx);
} catch (err) {
  ctx.logger.error('conversion failed', { err, job_id: job.id });   // logged once, here
  const { status, error } = toCallerEnvelope(err, {
    operation: ctx.operation_name,
    correlation_id: ctx.correlation_id
  });
  await jobs.markFailed(job.id, status, error);   // exactly what the caller was told
}
```

Which half to use: the wrapper handles the throw → `ErrorMapper.map()` (it logs);
the service handles it → `toCallerEnvelope` plus the service's own log line.
Never both for one failure. Contract:
[error-handling.md](/docs/biz/70-contracts/error-handling.md), the mapping table:
[handler-dispatch.md](/docs/biz/10-invocation/handler-dispatch.md).

## Health and registration

Registration sends the service name, version, specification endpoint,
`workspaceScoped` and the **validation proof** to
`registry.register`. From then
on the service publishes heartbeats — `{ serviceName, version, status,
timestamp, components: { mq, redis, db, cache }, correlationId }` — through the
same infrastructure mechanism the gateway and validator use; Registry's
`infrastructureHealthTracker` keeps the state. A failed heartbeat publish is
logged and does not crash the service.

`deployable` — the manifest-conformance verdict of the Tier-1 run (step 7/7) —
is NOT on that wire since 2026-09-17, and the projection into
`infrastructure:health:<service>` promised by
[biz-service-manifest.md](/docs/governance/confirmations/biz-service-manifest.md)
001 §4 falls with it (confirmation 011). Step 7 measures a git checkout, so the
production image — no `.git`, no compose file, no README — registered itself
`deployable: false` for a commit CI had proved green, and no consumer ever read
the key. What the uniform verdict rests on is the CI job `validate-uniform` of
every commit (confirmation 010); the boot log and `ci/deployability.json` still
carry the findings table for whoever is reading the run itself.

## Exports

`ServiceWrapper` (also the default export), `bootstrap`, `ConfigLoader`,
`HandlerRegistry`, `HandlerLoader`, `OperationContext`, `ContextBuilder`,
`SchemaValidator`, `ErrorMapper` and the business error classes,
`toCallerEnvelope`, `isBusinessError`, `BUSINESS_ERROR_BRAND`,
`setLogger` / `getLogger` (the shared logger proxy every biz service used to
copy into `src/lib/logger.js`; a service calls only `getLogger()` — `setLogger`
is for tests wiring an in-memory logger, and for FÁZE 0.12 itself), and
`VERSION`.

### The light entries — taking one thing without booting the wrapper

The package declares an `exports` map, so besides the root it serves three
subpaths, each loading its own module and nothing else:

| Entry | Module | What it gives |
|---|---|---|
| `@onlineapps/service-wrapper/errors` | `src/ErrorMapper.js` | `BusinessError` and the rest of the hierarchy, `isBusinessError`, `BUSINESS_ERROR_BRAND`, `toCallerEnvelope`, `ErrorMapper` |
| `@onlineapps/service-wrapper/logger` | `src/logger.js` | the `setLogger` / `getLogger` proxy pair |
| `@onlineapps/service-wrapper/schema-validator` | `src/SchemaValidator.js` | `SchemaValidator`, the Tier-1 validator the wrapper runs |

```javascript
// A service's own error class needs the base class, not the infrastructure.
const { BusinessError } = require('@onlineapps/service-wrapper/errors');
```

Why it exists: `require('@onlineapps/service-wrapper')` builds the whole
infrastructure layer — measured 5164 ms in the converter's container runner,
against 5 ms for `src/ErrorMapper.js` alone, and the cost lands on every CLI,
child process and test that only wanted to throw a typed error. Owner decision:
[/docs/governance/confirmations/wrapper-error-exports.md](/docs/governance/confirmations/wrapper-error-exports.md)
001, variant A.

**The map is the whole public surface.** Anything outside it — `…/src/…` above
all — now fails to resolve with `ERR_PACKAGE_PATH_NOT_EXPORTED`, which is the
point of declaring it: the package's file layout stops being an accidental
contract. A consumer that reached inside takes the matching entry from the table
instead. Proved by `tests/unit/packageExports.test.js` (working tree, through
Node's self-reference) and `tests/integration/package-exports.integration.test.js`
(the packed tarball, installed into a throwaway consumer).

## Running the tests

Two tiers, two runners, and they differ in what has to be running. `npm run test:unit`
needs no live stack: the connectors are served from the doubles in `tests/mocks/`, one
file per package name. `npm run test:integration` runs against the dev stack and never
skips. A tier that skipped would report green while executing nothing, so the tier states
its requirements and fails on a missing one instead:

| Variable | Refused by | When |
|---|---|---|
| `REDIS_URL` | `requireRedis()` (`tests/config.js`), called from `tests/integration/setup.js` | before the first describe tree, together with a TCP probe of the endpoint |
| `RABBITMQ_URL` | `requireRabbitMq()` (same two files) | the same |
| `RABBITMQ_MGMT_PORT` | `requireRabbitMq()` (same two files) | the same |
| `LOG_MAX_SIZE_BYTES` | `requireEnvKeys()` in `tests/integration/shutdown-closes-telemetry.integration.test.js` | in that suite's `beforeAll` — the probe it spawns boots a real wrapper, and `monitoring-core` 3.x declares no default |
| `LOG_MAX_FILES` | the same | the same |

That is the whole set, and it is the set below. The three keys every `ServiceWrapper`
constructor requires — `BIZ_SHUTDOWN_TIMEOUT_MS`, `REGISTRY_DISCOVERY_TIMEOUT_MS` and
`BIZ_HEARTBEAT_INTERVAL_MS` — are not in it: both tiers set them from their declaration in
`api/config/shared-env.json` (`tests/platformEnv.js`, called by `tests/setup.js` and
`tests/integration/setup.js`), so no runner exports them. The suites about a cadence or a
discovery deadline additionally read the value from the platform env file themselves
(`heartbeatCadence.integration.test.js`, `discoveryDeadline.integration.test.js`) or set
their own (`registryBootRefusal…`). `d1105-shutdownDeadline.integration.test.js` additionally needs a
Docker engine: it starts a Redis and a RabbitMQ of its own on free loopback ports, pauses
them, and removes them afterwards — never the dev stack's.

The host ports and the credentials are deliberately not written out here: they are
declared in `/infra/.env` (the ports docker compose publishes),
`/config/env-active/shared.env` (the in-network Redis and broker URLs, and the log limits)
and `/config/services.json` (`portConventions.externalPorts`, which the tier's own fix
messages quote) — the first two are not in git, and a copy in this file would be a second
source free to drift, for a password as much as for a port. Read them from there, swapping
each service's in-network host for the published one:

```bash
# from shared/connector/service-wrapper/
REDIS_URL="$(node -p "const f=require('fs'),u=new URL(/^REDIS_URL=(.*)/m.exec(f.readFileSync('../../../config/env-active/shared.env','utf8'))[1]);u.host='127.0.0.1:'+/^REDIS_PORT=(.*)/m.exec(f.readFileSync('../../../infra/.env','utf8'))[1];u.toString()")" \
RABBITMQ_URL="$(node -p "const f=require('fs'),u=new URL(/^RABBITMQ_URL=(.*)/m.exec(f.readFileSync('../../../config/env-active/shared.env','utf8'))[1]);u.host='127.0.0.1:'+/^RABBITMQ_PORT=(.*)/m.exec(f.readFileSync('../../../infra/.env','utf8'))[1];u.toString()")" \
RABBITMQ_MGMT_PORT="$(node -p "/^RABBITMQ_MGMT_PORT=(.*)/m.exec(require('fs').readFileSync('../../../infra/.env','utf8'))[1]")" \
LOG_MAX_SIZE_BYTES="$(/usr/bin/grep -m1 '^LOG_MAX_SIZE_BYTES=' ../../../config/env-active/shared.env | cut -d= -f2)" \
LOG_MAX_FILES="$(/usr/bin/grep -m1 '^LOG_MAX_FILES=' ../../../config/env-active/shared.env | cut -d= -f2)" \
npm run test:integration
```

`REDIS_URL` is ONE value for the whole endpoint — scheme, host, port, ACL user, password —
exactly as `src/ServiceWrapper.js` reads it in a running service. It replaced a
`REDIS_HOST` + `REDIS_PORT` pair that had nowhere to put a credential (d.674). In a running
service the same holds for every Redis connector the wrapper builds, and none of them
decomposes the URL: cache and state receive `wrapper.cache.url` whole (d.958); the platform
clients — the infrastructure gate, discovery (d.1091) and secrets (d.1096) — receive
`REDIS_URL` whole.

## Related documentation

- [docs/CONFIGURATION_GUIDE.md](./docs/CONFIGURATION_GUIDE.md) — every config key the wrapper reads
- [/docs/biz/80-decisions/0005-no-http-in-biz-containers.md](/docs/biz/80-decisions/0005-no-http-in-biz-containers.md) — the decision that removed the HTTP surface
- [/docs/biz/00-model/service-shape.md](/docs/biz/00-model/service-shape.md) — the canonical biz service shape and its standard levels
- [/shared/connector/conn-orch-validator/README.md](/shared/connector/conn-orch-validator/README.md) — what Tier-1 validation checks
- [/docs/standards/FALLBACKS_INVENTORY.md](/docs/standards/FALLBACKS_INVENTORY.md) §5.1 — the record that the bootstrap console fallback is gone

## License

PROPRIETARY - All rights reserved
