<p align="center">
  <a href="https://basaltkit-docs.pages.dev">
    <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
  </a>
</p>

# @basaltkit/core

The foundation of the Basalt framework: the "engine" that boots your application, wires the pieces together (plugins), holds the shared services (container), and maintains the context of each request. You need this whenever you create a Basalt application — every other `@basaltkit/*` package builds on top of it.

## What this module solves

As an application grows, it accumulates many pieces: database, email, job queues, authentication, etc. Without organization, each piece gets wired to the others "by hand", and it becomes hard to know in what order they should start, how they share objects with each other, and how they shut down correctly when the application terminates. `@basaltkit/core` solves exactly that.

The central idea is the **plugin**: a small module with a name (for example `basalt:cache`) that knows how to register its services, start up, and shut down. The application (`createApp`) receives a list of plugins, automatically orders them by their declared dependencies, and runs the full lifecycle: register → boot → (later) shutdown, in the right order.

For plugins to share services without knowing about each other directly, there's the **dependency injection container** (DI for short): a "box" where one plugin places a service identified by a **token** (a typed key) and any other plugin retrieves it by that token. The package also includes: **hooks** (internal notifications between plugins), **per-request context** (data like `requestId` available anywhere in the code), **metrics** in Prometheus format, and **tracing** (operation tracking) compatible with OpenTelemetry — all with no external dependencies.

## Installation

```bash
pnpm add @basaltkit/core
```

Requirements: Node.js (uses `node:async_hooks` and `node:crypto`) and TypeScript. The package is ESM (`"type": "module"`).

## Get started in 5 minutes

Let's create an application with two plugins: one provides a greeting service, and the other uses it.

1. Create a token to identify the service.
2. Create a plugin that registers the service in the container.
3. Create a second plugin that depends on the first and uses the service.
4. Boot the application.

```ts
import { createApp, createToken, definePlugin } from '@basaltkit/core'

// 1. The token is the service's "typed label" in the container.
interface Greeter {
  greet(name: string): string
}
const GREETER = createToken<Greeter>('greeter')

// 2. Plugin that provides the service (register phase: registration only, no I/O).
const greeterPlugin = definePlugin({
  name: 'app:greeter',
  register({ container }) {
    container.singleton(GREETER, () => ({
      greet: (name) => `Hello, ${name}!`,
    }))
  },
})

// 3. Plugin that consumes the service (boot phase: everything is available).
const helloPlugin = definePlugin({
  name: 'app:hello',
  dependsOn: ['app:greeter'], // ensures boot order
  boot({ container }) {
    console.log(container.get(GREETER).greet('Basalt'))
  },
})

// 4. Boot and shutdown.
const app = await createApp({ plugins: [greeterPlugin, helloPlugin] }).boot()
// ... the application runs ...
await app.shutdown()
```

When run, it prints `Hello, Basalt!`. Note that the order in the array doesn't matter: `dependsOn` ensures `app:greeter` registers and boots before `app:hello`.

## Usage guide

### Plugins and lifecycle

A plugin is a simple object with a name and up to three lifecycle functions, all optional:

- `register(context)` — phase 1: place services in the container. No external effects (no network connections, no files).
- `boot(context)` — phase 2: connect to databases, subscribe to hooks, start resources.
- `shutdown(context)` — shut down cleanly. Runs in **reverse order** of startup; if a plugin fails to shut down, the rest still shut down and the errors are aggregated into an `AggregateError`.

The `context` received at each phase has three fields: `container`, `hooks`, and `config` (the plugin's configuration slice, already validated — see below).

```ts
import { definePlugin } from '@basaltkit/core'

const dbPlugin = definePlugin({
  name: 'app:db',
  register({ container }) {
    /* register bindings */
  },
  async boot() {
    /* connect to the database */
  },
  async shutdown() {
    /* close the connection */
  },
})
```

### Validated per-plugin configuration

Each plugin can declare a `configSchema` (a "schema" is a description of the expected data shape, for validation). Any object with a `safeParse` method works — schemas from the [Zod](https://zod.dev) library are compatible. At startup, the `config[pluginName]` slice is validated; if it fails, `boot()` immediately throws `ConfigValidationError` (fail fast, before the application serves requests).

```ts
import { createApp, definePlugin } from '@basaltkit/core'
import { z } from 'zod'

const cachePlugin = definePlugin<{ driver: string }>({
  name: 'basalt:cache',
  configSchema: z.object({ driver: z.string() }),
  boot({ config }) {
    console.log(`Cache with driver: ${config.driver}`) // config already typed and validated
  },
})

await createApp({
  plugins: [cachePlugin],
  config: { 'basalt:cache': { driver: 'memory' } }, // keyed by plugin name
}).boot()
```

### Dependency injection container

The container holds "recipes" (*factories*: functions that create the service) associated with tokens. There are three lifetimes (`Lifetime`):

- `singleton` — a single instance for the whole application (the default).
- `scoped` — one instance per scope (for example, per HTTP request), created with `createScope()`.
- `transient` — a fresh instance on every `get()`.

```ts
import { Container, createToken } from '@basaltkit/core'

const COUNTER = createToken<{ n: number }>('counter')
const NAME = createToken<string>('name')

const container = new Container()
container.singleton(NAME, () => 'Basalt')
// The factory receives the container — that's how dependencies are injected:
container.singleton(COUNTER, (c) => ({ n: c.get(NAME).length }))

console.log(container.get(COUNTER).n) // 7

// Scopes (for example, one per request):
container.scoped(COUNTER, () => ({ n: 0 }))
const scopeA = container.createScope()
const scopeB = container.createScope()
scopeA.get(COUNTER).n = 10 // does not affect scopeB
```

The container detects cycles (A needs B which needs A) and throws `CircularDependencyError` with the full chain; an unregistered token throws `UnknownTokenError`.

### Captive dependencies — `CaptiveDependencyError`

A **captive dependency** is a short-lived service that gets trapped inside a long-lived
one. Concretely: a `singleton` resolves a `scoped` token *inside its factory*. The
singleton is built exactly once and lives for the whole process; the scoped instance it
grabbed belongs to whichever scope happened to be active at that moment — usually the
very first request. From then on every later request is served that first request's
object. If the scoped service carries the current user or the current tenant, this is a
cross-tenant data leak that no test written against a single request will ever catch.

Basalt refuses to build it. Resolving a `scoped` token while a `singleton` factory is in
flight throws `CaptiveDependencyError` (code `DI_CAPTIVE_DEPENDENCY`), naming both tokens:

```ts
const REQUEST_USER = createToken<User>('request:user')   // scoped
const REPORTS = createToken<Reports>('reports')          // singleton

container.scoped(REQUEST_USER, () => loadUserFromContext())
container.singleton(REPORTS, (c) => new Reports(c.get(REQUEST_USER)))
//                                              ^^^^^^^^^^^^^^^^^^^^
// throws CaptiveDependencyError: Scoped token "request:user" was resolved
// inside the factory of singleton "reports".
```

**How to fix it** — resolve the scoped service *at use time* instead of at construction:

```ts
// Option A: read it from the per-request scope when the method runs.
container.singleton(REPORTS, () => new Reports(() => (ctx().container as Container).get(REQUEST_USER)))

// Option B: make the consumer scoped too, so it is rebuilt per request.
container.scoped(REPORTS, (c) => new Reports(c.get(REQUEST_USER)))

// Option C: make the dependency transient, if a fresh one per call is correct.
container.transient(REQUEST_USER, () => loadUserFromContext())
```

The check is an O(1), allocation-free counter of in-flight singleton builds — there is no
cost on the happy path. `transient` dependencies of a singleton are unaffected: they are
rebuilt on every resolution, so nothing is captured.

### Devtools: seeing the graph

Recording is off by default (zero overhead). Turn it on and the container remembers every
`A depends on B` edge it actually resolves — it never forces eager construction:

```ts
import { renderDependencyGraph } from '@basaltkit/core'

container.enableGraph()
// ... let the app boot and serve a request ...
container.describe()          // [{ token, lifetime, instantiated }] — every reachable binding
container.dependencyGraph()   // { nodes: [{ token, lifetime }], edges: [{ from, to }] }
console.log(renderDependencyGraph(container.dependencyGraph())) // a Mermaid `graph TD`
```

### Hooks (notifications between plugins)

`HookBus` lets one plugin announce events ("hooks") and others react, without knowing about each other. The application itself emits `app:registered`, `app:booted`, and `app:shutdown`.

```ts
import { HookBus } from '@basaltkit/core'

const hooks = new HookBus()

// Handler with priority: higher values run first (default 0).
const off = hooks.on('app:booted', ({ app }) => {
  console.log('The application has booted!')
}, { priority: 10 })

// Listens to ALL emissions (useful for auditing/devtools); runs after specific handlers.
hooks.onAny((hook, payload) => console.log(`hook: ${hook}`))

await hooks.emit('app:booted', { app })
off() // cancel the subscription
```

Packages can add typed hooks via *module augmentation* (Advanced):

```ts
declare module '@basaltkit/core' {
  interface BasaltHooks {
    'tenancy:switched': { tenantId: string }
  }
}
```

### Per-request context (`ctx`)

The context carries request data (like `requestId`) through the whole call stack, even across `await`s, without manually passing arguments. Uses Node's `AsyncLocalStorage`.

```ts
import { ctx, runWithContext, tryCtx } from '@basaltkit/core'

async function deepService(): Promise<string> {
  // works at any depth, without receiving the id as a parameter:
  return ctx().requestId as string
}

const result = await runWithContext({ requestId: 'req-1' }, async () => {
  return deepService()
})
console.log(result) // 'req-1'

// Outside an active context: ctx() throws ContextUnavailableError; tryCtx() returns undefined.
console.log(tryCtx()) // undefined
```

Concurrent contexts don't mix: each `runWithContext` has its own.

#### Await lazy queries *inside* the callback

`runWithContext` returns whatever `fn` returns, and the context is only active while `fn` runs. A **lazy thenable** — a Prisma `PrismaPromise`, a query builder — does no work until something calls `.then()` on it. Returned from a synchronous callback, it is executed by the caller's `await`, **outside** the context, so the tenant is missing (the Prisma extension then fails closed with `PRISMA_TENANT_MISSING`):

```ts
// `prisma` is a client extended with @basaltkit/prisma's tenant scoping.

// Wrong: the query runs after runWithContext has returned
await runWithContext({ tenant }, () => prisma.invoice.findMany())

// Right: an async callback awaits the query while the context is active
await runWithContext({ tenant }, async () => await prisma.invoice.findMany())
```

`tenancy.run(tenant, fn)` from `@basaltkit/tenancy` already wraps `fn` in an async scope, so it is not affected.

### Human-readable durations

```ts
import { parseDuration } from '@basaltkit/core'

parseDuration('30s')  // 30000 (milliseconds)
parseDuration('1.5d') // 129600000
parseDuration(1500)   // 1500 (numbers pass through directly)
// 'abc', '-5s', NaN → throws BasaltError with code 'DURATION_INVALID'
```

Accepted units: `ms`, `s`, `m`, `h`, `d`.

### Production or not: `isProductionEnvironment()`

The one, fail-closed `NODE_ENV` policy every Basalt package uses to pick a
security default (`Secure` cookies, the JWT secret length floor, mail-body
redaction, the queue's sync-driver warning, `secret()` in `@basaltkit/env`):

```ts
import { isProductionEnvironment } from '@basaltkit/core'

isProductionEnvironment()               // reads process.env.NODE_ENV
isProductionEnvironment('development')  // false
isProductionEnvironment('test')         // false
isProductionEnvironment('staging')      // true — so is unset, '' or a typo
```

Only an **explicit** `development` or `test` is non-production. A deploy that
forgets `NODE_ENV` gets the strict defaults, never the development ones. Vitest
sets `NODE_ENV=test` when it is unset, so test suites need no configuration.

### Metrics (Prometheus format)

Counters, gauges, and histograms that export as text in [Prometheus](https://prometheus.io) format (a popular monitoring system) — enough for a `/metrics` endpoint.

```ts
import { MetricsRegistry } from '@basaltkit/core'

const registry = new MetricsRegistry()

const requests = registry.counter('http_requests_total', {
  help: 'Total HTTP requests',
  labelNames: ['method'],
})
requests.inc({ method: 'GET' })
requests.inc({ method: 'GET' })

const inFlight = registry.gauge('in_flight')
inFlight.inc() // goes up
inFlight.dec() // goes down
inFlight.set(42)

const duration = registry.histogram('request_duration_seconds', {
  buckets: [0.1, 0.5, 1],
})
duration.observe(0.2)

console.log(registry.render()) // text ready for the /metrics endpoint
```

Requesting a metric with the same name always returns the same instance. Counters reject negative increments.

### Tracing (distributed tracing)

*Tracing* means recording how long each operation (a *span*) took and how they chain across services. The implementation follows the W3C `traceparent` standard and exports to any OpenTelemetry collector via OTLP/HTTP — without installing the OpenTelemetry SDK.

```ts
import { ConsoleSpanExporter, Tracer } from '@basaltkit/core'

const tracer = new Tracer({
  serviceName: 'my-api',
  exporter: new ConsoleSpanExporter(), // prints a summary per span
})

const span = tracer.startSpan('GET /users', { kind: 'server' })
await tracer.inSpan(span, async () => {
  // spans created inside here automatically become children
  const child = tracer.startSpan('db.query')
  child.setAttribute('db.table', 'users')
  child.end()
})
// inSpan ends the span, and marks status 'error' if the function throws
```

For production, use `OtlpHttpExporter` (sends to `http://<collector>:4318/v1/traces` in batches) and, to continue a trace coming from another service, pass `parseTraceparent(headers['traceparent'])` as the span's `parent`.

## API reference

### Plugin lifecycle phases

`BasaltApp.phase` walks a fixed path, and each transition means something concrete:

| Phase | What has happened | What is safe here |
|---|---|---|
| `created` | `createApp()` returned; nothing has run. | Nothing yet — `container.get()` will find no bindings. |
| `registering` | Every plugin's `register()` is running, in `dependsOn` order. | Register bindings. **No I/O** — the services you depend on may not be registered yet. |
| — | `app:registered` is emitted. | Every binding exists. |
| `booting` | Every plugin's `boot()` is running, in the same order. | Resolve services, connect, subscribe to hooks, publish metadata. Adapters run their guarded-meta check here. |
| `ready` | `app:booted` is emitted, then `boot()` returns. | Serve traffic. Edge plugins mount their hooks/routes on this hook, which is why plugin order doesn't matter for them. |
| `shutting-down` | Every `shutdown()` runs in **reverse** boot order. | Close connections. A failure here doesn't stop the others; they aggregate. |
| `stopped` | `app:shutdown` emitted, done. | `shutdown()` is idempotent — calling it again is a no-op. |

`boot()` may be called only once per app; a second call throws `LifecycleError`. Anything
that must observe *all* plugins (an OpenAPI document, an adapter mounting edge routes)
belongs on the `app:booted` hook, not in a `boot()` body.

### `createToken<T>(description)` / `Token<T>`

Creates a typed DI token. The type `T` only exists at compile time (there is no runtime reflection). Two tokens with the same description are **different** (each has its own `symbol`).

### `Container`

| Method | Description |
|---|---|
| `register(token, factory, lifetime?)` | Registers a factory. `lifetime` defaults to: `'singleton'`. Returns `this`. |
| `singleton(token, factory)` | Shortcut for `register(..., 'singleton')`. |
| `scoped(token, factory)` | One instance per scope (created in the leaf container). |
| `transient(token, factory)` | A fresh instance on every resolution. |
| `get(token)` | Resolves the service. Throws `UnknownTokenError` or `CircularDependencyError`. |
| `has(token)` | `true` if a binding exists (here or in the parent container). |
| `createScope()` | Creates a child container: inherits bindings, does not inherit `scoped` instances. |
| `enableGraph()` | Starts recording the dependency graph (off by default). Returns `this`. |
| `dependencyGraph()` | The `{ nodes, edges }` observed so far. Empty until `enableGraph()`. |
| `describe()` | `BindingInfo[]` — `{ token, lifetime, instantiated }` for every reachable binding. |

`Factory<T>` = `(container: Container) => T`. `Lifetime` = `'singleton' | 'scoped' | 'transient'`.

### `definePlugin(plugin)` / `BasaltPlugin<TConfig>`

`definePlugin` simply returns the object with typing — it's syntactic sugar for autocompletion.

| Field | Type | Required? | Default | Description |
|---|---|---|---|---|
| `name` | `string` | yes | — | Unique name; convention `basalt:<package>` or `app:<name>`. |
| `dependsOn` | `string[]` | no | `[]` | Plugins that register/boot before this one. |
| `configSchema` | `ConfigSchema<TConfig>` | no | — | Object with `safeParse` (Zod-compatible); validates the config slice. |
| `register` | `(ctx) => void \| Promise<void>` | no | — | Phase 1: register bindings, no I/O. |
| `boot` | `(ctx) => void \| Promise<void>` | no | — | Phase 2: connect resources, subscribe to hooks. |
| `shutdown` | `(ctx) => void \| Promise<void>` | no | — | Shut down; runs in reverse order. |

`PluginContext<TConfig>` = `{ container, hooks, config }`.

### `createApp(options)` / `BasaltApp`

| Option (`CreateAppOptions`) | Type | Required? | Default | Description |
|---|---|---|---|---|
| `plugins` | `BasaltPlugin[]` | no | `[]` | Plugins; topologically ordered by `dependsOn`. |
| `config` | `Record<string, unknown>` | no | `{}` | Raw config, keyed by each plugin's name. |

Members of `BasaltApp`: `container`, `hooks`, `phase` (`LifecyclePhase` = `'created' | 'registering' | 'booting' | 'ready' | 'shutting-down' | 'stopped'`), `boot()` (can only be called once; otherwise throws `LifecycleError`), and `shutdown()` (idempotent). Emitted hooks: `app:registered`, `app:booted`, `app:shutdown`.

### `HookBus`

| Method | Description |
|---|---|
| `on(hook, handler, { priority? })` | Subscribes; higher `priority` runs first (default 0). Returns a function to cancel. |
| `onAny(handler)` | Receives `(hook, payload)` from every emission, after the specific handlers. |
| `emit(hook, payload)` | Runs handlers **in series** by priority, `await`ing each, then the `onAny` observers. |

Handlers are **isolated**: one throwing never starves the remaining handlers or the
`onAny` observers (audit, devtools) — every registered handler always runs. Failures still
surface to the emitter afterwards: a single failure rethrows the original error, several
become an `AggregateError`. Nothing is ever swallowed silently.

### Context

| Function | Description |
|---|---|
| `ctx()` | The active context (`RequestContext`); throws `ContextUnavailableError` outside a scope. |
| `tryCtx()` | The active context, or `undefined`. |
| `runWithContext(context, fn)` | Runs `fn` with the active context (propagates across `await`s and callbacks) and returns its result unchanged. Use an **async** callback that awaits lazy thenables (e.g. Prisma queries) inside it — see *Await lazy queries inside the callback* above. |

`RequestContext` has `requestId?`, `correlationId?`, and accepts extra keys (extensible via *module augmentation*).

### `parseDuration(input)`

`DurationInput` = `number | string`. Converts to milliseconds; throws `BasaltError` (`DURATION_INVALID`) if invalid.

### `isProductionEnvironment(nodeEnv?)`

`(nodeEnv: string | undefined = process.env.NODE_ENV) => boolean`. `false` only for `'development'` and `'test'`; `true` for anything else, including unset (and on a runtime without `process`).

### Errors

All extend `BasaltError`, which has a stable `code` (you can safely do `if (error.code === '...')`). None of them carries an HTTP status — `@basaltkit/core` knows nothing about HTTP, so an adapter maps anything without a numeric `status` to `500 INTERNAL_ERROR`:

| Class | `code` | When |
|---|---|---|
| `ContextUnavailableError` | `CONTEXT_UNAVAILABLE` | `ctx()` called outside `runWithContext` — use `tryCtx()` when a context is optional. |
| `UnknownTokenError` | `DI_UNKNOWN_TOKEN` | `container.get(TOKEN)` with nothing registered for it. |
| `CircularDependencyError` | `DI_CIRCULAR_DEPENDENCY` | A resolution cycle; the message lists the full chain. |
| `CaptiveDependencyError` | `DI_CAPTIVE_DEPENDENCY` | A `scoped` token was resolved inside a `singleton` factory — see above. |
| `PluginDependencyError` | `PLUGIN_DEPENDENCY` | Duplicate plugin name, a missing `dependsOn`, or a plugin cycle. |
| `ConfigValidationError` (fields `plugin`, `issues`) | `CONFIG_INVALID` | The plugin's `config` slice failed its `configSchema`, at boot. |
| `LifecycleError` | `LIFECYCLE` | `boot()` called more than once. |
| `BasaltError` | `DURATION_INVALID` | `parseDuration` got something it can't parse. |

`shutdown()` does not use a `BasaltError`: if several plugins fail to shut down it throws a native `AggregateError` — every other plugin still shuts down first.

`new BasaltError(code, message, options?)` takes `BasaltErrorOptions` — the standard `cause`, plus an optional structured payload:

| Option | Type | Description |
|---|---|---|
| `cause` | `unknown` | Standard `ErrorOptions.cause`. |
| `details` | `Record<string, unknown>` | Machine-readable data about the failure — which checks failed, how much quota is left, the current version behind a conflict. Kept exactly as given; **core never sanitises it**. |

`details` matters for an error that also carries a numeric `status`: `@basaltkit/http` serialises a sanitised copy of it as `error.details` in the HTTP body, which makes it **public**. No secrets, no internals, plain JSON data only, and small — the rules (and the 4 KiB cap) live in `@basaltkit/http`'s "Structured error details".

```ts
class QuotaExceededError extends BasaltError {
  readonly status = 402
  constructor(limit: number, used: number) {
    super('QUOTA_EXCEEDED', 'Plan quota exceeded.', { details: { limit, used } })
  }
}
```

### Metrics

- `MetricsRegistry`: `counter(name, options?)`, `gauge(name, options?)`, `histogram(name, options? & { buckets? })`, `render()`.
- `MetricOptions`: `help?` (default: the name itself), `labelNames?` (default `[]`).
- `Counter.inc(labels?, value?)` — `value` defaults to 1, never negative.
- `Gauge.set(value, labels?)`, `inc(labels?, value?)`, `dec(labels?, value?)`.
- `Histogram.observe(value, labels?)`; `buckets` default `DEFAULT_BUCKETS` (`[0.005 … 10]` seconds).
- `Metric` (abstract class) and `Labels` = `Record<string, string>`.

### Tracing

- `Tracer(options?)` — `TracerOptions`: `exporter?`, `serviceName?` (default `'basalt'`), `sampled?` (default `true`), `clock?` (default `Date.now`), `idGenerator?`.
- `tracer.startSpan(name, { parent?, kind?, attributes? })` — `kind` defaults to `'internal'`; without `parent`, uses the active span as parent.
- `tracer.inSpan(span, fn)` — activates the span, marks `error` if `fn` throws, always ends it.
- `tracer.forceFlush()` — forces the exporter to send.
- `Span`: `setAttribute(key, value)`, `setStatus(status, message?)`, `end()` (idempotent).
- `activeSpan()` — the current span, or `undefined`.
- `parseTraceparent(header)` / `formatTraceparent(context)` — W3C header.
- Exporters (`SpanExporter`): `InMemorySpanExporter` (tests), `ConsoleSpanExporter` (dev), `OtlpHttpExporter` (`OtlpHttpExporterOptions`: `url` required, `serviceName?`, `headers?`, `maxBatch?` default 100, `fetchImpl?`). Sending is *best-effort*: network failures never break the request.
- `toOtlpJson(spans, serviceName)` (Advanced) — serializes to OTLP/JSON format.
- Types: `SpanContext`, `FinishedSpan`, `SpanKind`, `SpanStatus`, `AttributeValue`.

### Secrets at rest — `@basaltkit/core/secret-box`

`createSecretBox({ keys, info, version, aadFields })` returns
`{ version, activeKeyId, seal, open, isCurrent, keyIdOf, reseal }`: AES-256-GCM
with HKDF-SHA256 keys from a key ring (the first key seals, all open), every
ciphertext bound to `aadFields` ordered context strings (no NUL), envelope
`<version>.<keyId>.<iv>.<tag>.<ciphertext>`, and no plaintext path. `reseal`
returns `null` for an envelope already on the active key, after authenticating
it. Failures throw `SecretBoxError` (`code: 'SECRET_BOX_ERROR'`, `failure`:
`config` | `malformed` | `unknown-key` | `context` | `auth-failed`). It backs
`@basaltkit/auth`'s `SecretBox` and `@basaltkit/drives`' `DriveSecretBox`, and
is exported only from this subpath, not from the main entry.

### Metadata (Advanced)

`MetadataRegistry` (`add(bucket, entry)`, `get(bucket)`, `bucketNames()`), the `METADATA` token, and `ensureMetadata(container)` — a central registry of what each plugin declared (routes, commands, schedules), read by tooling (CLI, docs) without importing the producing package.

## Common errors and solutions (FAQ)

**"No provider registered for token …" (`DI_UNKNOWN_TOKEN`)** — You called `container.get(TOKEN)` but no plugin registered that token. Check that the plugin providing it is in the `plugins` list and that the consumer has `dependsOn` pointing to it.

**"ctx() was called outside of an active context" (`CONTEXT_UNAVAILABLE`)** — You called `ctx()` outside `runWithContext`. Wrap the entry point (HTTP handler, worker) with `runWithContext({...}, fn)`, or use `tryCtx()` when a context is optional.

**`PRISMA_TENANT_MISSING` from a query wrapped in `runWithContext`** — The callback returned a lazy `PrismaPromise` without awaiting it, so the query ran after the context ended. Make the callback `async` and `await` the query inside it.

**"boot() called in phase …" (`LIFECYCLE`)** — `boot()` can only be called once per application. Create a new app with `createApp()` if you need to boot again (useful in tests).

**"Plugin X depends on Y, which was not added to the app" (`PLUGIN_DEPENDENCY`)** — Plugin `Y` is missing from the `plugins` array. The same error appears for duplicate plugins and for cycles (`a -> b -> a`).

**"Invalid configuration for plugin …" (`CONFIG_INVALID`)** — The `config['plugin-name']` slice didn't pass `configSchema`. Confirm the key in the `config` object exactly matches the plugin's `name`.

**Two "equal" tokens behave as different ones** — This is intentional: each `createToken` creates a new `symbol`. Export the token from a shared module and import it everywhere, instead of recreating it.

## How it connects to other modules

- **`@basaltkit/config`** — provides `configPlugin`, which registers a `ConfigRepository` in the core container via the `CONFIG` token.
- **`@basaltkit/env`** — uses core's `BasaltError` for its `EnvValidationError`; usually the first step before assembling the `config` object you pass to `createApp`.
- **`@basaltkit/events`** — provides `eventsPlugin` (an event bus on the `EVENTS` token) and `outboxPlugin`; both are core plugins, and the outbox uses `tryCtx()` to read the tenant from the context.
- **`@basaltkit/http` and the adapters** — the request pipeline creates a `RequestContext` per request and a per-request container scope (`ctx().container`), which is where `scoped` bindings live.
- Any package in the ecosystem extends the `BasaltHooks` and `RequestContext` types via *module augmentation* to add typed hooks and context fields.

Guides: [Concepts](/guide/concepts) · [Adapters](/guide/adapters) · [Configuration](/guide/config) · [Observability](/guide/observability)
