---
name: extends-config
description: How the authored config's extends chains work (bos.app.ts preferred; legacy bos.config.json supported), deep merge semantics, resolved config lifecycle, env-specific extends, and canonical field ordering. Use when debugging extends inheritance, configuring per-environment parents, understanding what dev writes vs publish writes, or reasoning about config merging.
metadata:
  sources: "packages/everything-dev/src/merge.ts,packages/everything-dev/src/config.ts,packages/everything-dev/src/shared-deps.ts,packages/everything-dev/src/types.ts"
---

> **Config form:** the authored config is `bos.app.ts` (canonical, preferred when both exist). A legacy `bos.config.json` is still supported for older children. Where this doc says `bos.config.json` for the *local authored file*, read "the authored config". The published artifact on FastKV keeps the key name `bos.config.json`.

# extends & Config Merging

## extends Field

The `extends` field in the authored config (`bos.app.ts`, or legacy `bos.config.json`) specifies a parent config to inherit from. Supports two forms:

### String (all environments use same parent)
```json
{ "extends": "bos://dev.everything.near/everything.dev" }
```

### Object (per-environment parent)
```json
{
  "extends": {
    "development": "bos://dev.everything.near/everything.dev",
    "production": "bos://dev.everything.near/everything.dev",
    "staging": "bos://staging.everything.near/everything.dev"
  }
}
```

Fallback chain: requested env → `production` → first defined value.

## Deep Merge Semantics

Uses `defu` (with `createDefu` for custom merge rules):

| Field type | Merge behavior |
|-----------|---------------|
| Scalars (account, domain, repository) | Child overrides parent; parent inherited when child omits |
| `app.api.shared`, `app.auth.shared`, `plugins.*.shared` dep entries | Deep merged — child overrides specific keys, parent deps preserved |
| `plugins` | Deep merged — child overrides per-key, parent plugins preserved unless removed |
| `secrets` arrays | Unioned (deduplicated) |
| `routes` arrays | Child replaces parent |
| `variables` | Deep merged per-key |

### Null Sentinel Removal

Set a plugin to `null` to explicitly remove an inherited plugin:
```json
{
  "plugins": {
    "template": null
  }
}
```

## Fixed-Core Tenant Mode

For shared-host tenant setups, `extends` is both the config inheritance mechanism and the lineage edge between runtimes.

Example:

```json
{
  "extends": "bos://pingpayio.near/pingpay.io",
  "account": "pizza.pingpayio.near",
  "domain": "pizza.com"
}
```

Use this mental model:
- `extends` says which runtime this one remixes or inherits from
- `account` is the tenant namespace root for this runtime when it is served as a base app
- `domain` is the public ingress for this runtime
- a runtime can be a child in lineage and still become a new tenant root on its own domain

Tenant permissions come from the DB, not env vars. The host fetches the tenant bindings map from the API's `GET /tenants/bindings` endpoint (cached for 30s), and the tenant record's allow flags gate what may be overridden and whether SSR is permitted.

Design target for request mapping:
- `pizza.com` -> base runtime `bos://pizza.pingpayio.near/pizza.com`
- `chicago.pizza.com` -> `bos://chicago.pizza.pingpayio.near/pizza.com`

Current implementation note:
- the host still applies a single request-scoped tenant overlay on top of one process-wide base runtime
- nested label routing and account-relative tenant derivation are the intended direction, but not the complete runtime behavior today

### What the tenant may override today

In fixed-core mode, the host keeps the server core from the active base runtime and only applies request-scoped UI-facing overrides from the child config:
- `app.ui`
- existing `plugins.<id>.ui`
- existing `plugins.<id>.sidebar`

The tenant config must extend the base BOS runtime. Tenant API/auth overrides and dynamic new plugin IDs are not part of this mode.

### SSR behavior

Tenant SSR is gated separately from inheritance:
- a tenant may SSR only when its record has `allow_ssr` set
- tenants without `allow_ssr` fall back to client rendering

## Resolved Config: `.bos/bos.resolved-config.json`

**Generated by**: `bos dev`, `bos build`, `syncResolvedSharedDeps()`  
**Gitignored**: Yes (inside `.bos/`)

When `bos dev` or `bos build` runs:
1. The full extends chain is resolved in memory
2. The merged config is written to `.bos/bos.resolved-config.json`
3. **The authored config is NOT modified** during dev

Structure:
```json
{
  "_resolved": {
    "env": "development",
    "resolvedAt": "2026-05-11T...",
    "extendsChain": ["bos://dev.everything.near/everything.dev"]
  },
  "account": "me.near",
  "domain": "my.dev",
    "app": { ... },
    "plugins": { ... }
  }
```

### Build configs read resolved config first

All build configs (ui/rsbuild.config.ts, host/rsbuild.config.ts, api/rspack.config.js, plugins/*/rspack.config.js) try `.bos/bos.resolved-config.json` first, falling back to the authored config.

The `_resolved` metadata is stripped before use.

### When the authored config IS written

| Command | Writes the authored config? | Why |
|---------|------------------------|-----|
| `bos dev` | No | Uses resolved config |
| `bos build` | No | Uses resolved config |
| `bos publish --deploy` | Yes | Snapshot moment — pins production URLs + versions |
| `bos plugin publish` | Yes | Records production URL + integrity |
| `bos plugin add/remove` | Yes | Changes project's own plugin list |
| `bos sync` | Yes | Merges template updates into local config |

### Remote host mode (bos->catalog)

When host is remote, `syncResolvedSharedDeps()` reads versions from the authored config and writes them into `package.json` catalog. No resolved config is written — the remote host reads the published config directly.

### `_resolved.resolvedAt`

The `resolvedAt` timestamp in `.bos/bos.resolved-config.json` is metadata for debugging only. It is not used for staleness detection — the CLI always re-resolves on `bos dev` / `bos build`.

## Circular Extends Detection

If the extends chain contains a cycle (e.g., A→B→A), the resolver detects it during config loading and throws an error listing the cycle path. This prevents infinite recursion during merge.

Detection occurs in `resolveExtendsRef()` → `mergeBosConfigWithExtends()`. The extends chain is tracked as a set of visited BOS refs; a duplicate visit triggers the error.

## Canonical Field Ordering

`BOS_CONFIG_ORDER` enforces consistent key order:

1. `extends` — always first
2. `account`
3. `domain`
4. `title`
5. `description`
6. `testnet`
7. `staging`
8. `repository`
9. `ci`
10. `app`
11. `plugins`

Unknown keys go after known keys. `rebuildOrderedConfig()` is applied before every write.

## API

From `packages/everything-dev/src/config.ts`:
- `writeResolvedConfig(configDir, config, env, extendsChain?)` — writes `.bos/bos.resolved-config.json`
- `loadResolvedConfig(configDir)` — reads resolved config, returns `BosConfig | null`
- `resolveBosConfigPath(configDir)` — returns resolved config path if exists, else the authored config
- `readBosConfigForBuild(configDir)` — reads resolved config stripping `_resolved`, falls back to the authored config

From `packages/everything-dev/src/merge.ts`:
- `mergeBosConfigWithExtends(parent, child)` — deep merge for extends chain
- `mergeBosConfigWithTemplate(local, template)` — merge for sync (local wins)
- `resolveExtendsRef(extendsField, env)` — resolve string|object extends for a given env
- `rebuildOrderedConfig(config)` — enforce canonical ordering
- `BOS_CONFIG_ORDER` — ordered field names

## The tasks you will actually be given

**"Remove a plugin I inherited."** Set it to `null` in the authored config's `plugins` — the null sentinel deletes it from the merged result. Deleting the entry entirely just inherits the parent's.

**"Different parent for staging."** Object-form `extends` keyed by env (`development` / `staging` / `production`); the fallback chain is requested env → `production` → first defined value.

**"Why doesn't the runtime see my config edit?"** Dev reads `.bos/bos.resolved-config.json` (regenerated on every `bos dev`); production reads FastKV. The authored config is only the publish input — `bos publish` is the write.

## What comes back when it refuses

| word | do |
|---|---|
| `CircularExtendsError` — "Circular extends detected: A -> B -> A" | stop — the extends chain loops; restructure so no runtime extends itself transitively |
| `Circular dependency detected among: … dependsOn` | build-order cycle in the build train — fix the `dependsOn` declarations in the authored config |
| inherited plugin still present after "removing" it | deep merge keeps parent plugins unless nulled — you need the explicit `null` sentinel, keyed exactly as the parent's plugin key |
| your section is missing from `.bos/bos.resolved-config.json` | ordering never drops keys — if it's absent, the authored config or its parent didn't define it; check the `extends` chain with `bos config --full` |
