diff --git a/.changeset/discovery-node-env-unset-production.md b/.changeset/discovery-node-env-unset-production.md
new file mode 100644
index 0000000000..67b1e9e2a1
--- /dev/null
+++ b/.changeset/discovery-node-env-unset-production.md
@@ -0,0 +1,44 @@
+---
+"@objectstack/runtime": minor
+"@objectstack/cli": minor
+---
+
+fix(runtime,cli): 未设置 `NODE_ENV` 时 `/discovery` 不再自称 `development`,统一按 `production` 解读 (#5673)
+
+同一个「宿主没有设置 `NODE_ENV`」的事实,仓里原本有两套相反的默认:`os start` 未设时强制
+`NODE_ENV='production'`(`start.ts:248`),`os serve` 与 `os doctor` 按
+`NODE_ENV || 'production'` 解析 `.env*` 级联,而 `/discovery` 的 `environment` 字段
+直接把缺省读成 `development`。
+
+**为什么这个方向的错报是危险的那个。** `environment` 是**机器可读面**上的字段,客户端
+拿它回答「我在不在生产环境」,并可能据此不显示生产警示、放宽破坏性操作的二次确认。一个
+忘记设 `NODE_ENV` 的真实生产部署,过去会拿到 `development` —— 两种错法里代价更高的那种。
+按 maintainer 2026-08-06 裁定,缺省统一收敛到保守值 `production`。
+
+**迁移说明(行为变更,请对照自己的部署方式读)**
+
+- **生产部署忘设 `NODE_ENV`**:`/discovery` 的 `environment` 由 `development` 变为
+ `production`。这正是本次修复的目标 —— 报的是实情,不需要任何动作。
+- **本地开发**:不受影响。`os dev` 会 spawn `serve --dev`,而 `serve` 在 `--dev` 且
+ `NODE_ENV` 未设时就地设 `NODE_ENV='development'`(`serve.ts:490-491`),所以
+ `pnpm dev` / `pnpm dev:showcase` / `dev:crm` / `dev:todo` 链路上 `NODE_ENV` 早已是
+ 显式的 `development`,`/discovery` 仍报 `development`。没有任何脚本因此改动。
+- **需要 `development` 却不走 `os dev` 的场景**(裸 `os serve`、以库形式内嵌运行时、
+ 自建容器入口):现在必须显式 `NODE_ENV=development`。这是本次唯一需要动手的一类。
+- **已设置的合法拼法一律不变**:`production`/`prod` → `production`,
+ `staging`/`sandbox` → `sandbox`,`development`/`dev`/`test` → `development`。
+- **无法识别的拼法处置不回退**:`qa`、`preview`、`uat` 这类**设了但认不出**的值仍然
+ 降级为 `development`,#4828 的「绝不凭猜测宣称 production」保持原样。缺省不是猜测,
+ 是宿主选择不说 —— 两条是不同的规则,本次只动前者。
+
+**`os doctor` 新增一行提示。** `NODE_ENV` 未设时报
+`NODE_ENV Not set — this environment is being treated as production`(warning,不影响
+退出码),`--verbose` 展开显式设置的两条命令。已设置的环境完全没有这一行,报告与从前
+逐字一致。统一默认让缺省变得**安全**,但也让「疏忽」和「有意的生产部署」变得无法区分;
+这一行是唯一能把两者分开的地方。
+
+**已知残留(已另开 #5936 跟进,本次不动)。** `/discovery` 有两个生产者。本次改的是
+`@objectstack/runtime` 的 `HttpDispatcher.getDiscoveryInfo()`;经 `@objectstack/rest`
+暴露的 `MetadataProtocol.getDiscovery()`(`packages/metadata-protocol`)把真实缺省原样
+递给共享映射函数,该函数对缺省仍返回 `development`。裁定把落点限定在 runtime 侧、把
+`packages/metadata-protocol` 标为跨域文件面,所以此处如实记录而非顺手绕过。
diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx
index 165d15a09c..dc3c97423c 100644
--- a/content/docs/protocol/kernel/http-protocol.mdx
+++ b/content/docs/protocol/kernel/http-protocol.mdx
@@ -175,7 +175,30 @@ than advertised verbatim (#4828):
| `staging` | `sandbox` — pre-production and production-like |
| `development`, `dev` | `development` |
| `test` | `development` — an ephemeral developer-class run |
-| unset / anything else | `development` — never claims production on a guess |
+| **unset** (absent, or `NODE_ENV=`) | `production` — the conservative reading of "the host did not say" (#5673) |
+| anything else | `development` — never claims production on a guess |
+
+The last two rows answer two different questions and were one row until #5673. **Unset is
+not a spelling**, it is the absence of one, and the rest of the platform already read that
+absence as production: `os start` forces `NODE_ENV=production` when it is unset, and both
+`os serve` and `os doctor` resolve the `.env*` cascade for `NODE_ENV || production`. This
+field is machine-readable — a client uses it to decide whether it is talking to production
+— so a real production deployment whose operator forgot the variable must not be told
+`development`. **An unrecognised spelling** (`qa`, `preview`, `uat`) is a different case: it
+is a guess, and this field never claims production on a guess.
+
+Local development is unaffected: `os dev` runs `serve --dev`, which sets
+`NODE_ENV=development` in-process before the runtime loads. Anything that boots the runtime
+*without* `os dev` — a bare `os serve`, an embedded host, a hand-written container entry
+point — must now set `NODE_ENV=development` explicitly to keep being advertised as such.
+
+
+**The `@objectstack/rest`-served `/api/v1/discovery` document still reads an unset
+`NODE_ENV` as `development`.** #5673 landed on the dispatcher producer only; the second
+producer is tracked in [#5936](https://github.com/objectstack-ai/objectstack/issues/5936).
+Until it lands, the two documents agree on every *set* value and differ only when nothing
+is set.
+
**Retired in protocol 17 (#4828):** this document used to carry a top-level `features`
diff --git a/packages/cli/src/commands/doctor-node-env-default.test.ts b/packages/cli/src/commands/doctor-node-env-default.test.ts
new file mode 100644
index 0000000000..829a602816
--- /dev/null
+++ b/packages/cli/src/commands/doctor-node-env-default.test.ts
@@ -0,0 +1,227 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os doctor`'s unset-`NODE_ENV` row (#5673).
+ *
+ * ── What #5673 was ───────────────────────────────────────────────────────
+ *
+ * One fact — "the operator never set `NODE_ENV`" — had two opposite readings in
+ * this repo. `os start` forced `production` (`start.ts:248`), `os serve` and
+ * `doctorNodeEnv()` derived `NODE_ENV || 'production'`, and the `/discovery`
+ * `environment` field advertised `development`. The maintainer's 2026-08-06
+ * ruling unified them on `production` — the conservative answer, because a
+ * client reads `environment` to decide whether it is talking to production and
+ * the dangerous direction of error is claiming `development` on a real one.
+ *
+ * Unifying the readings makes the default SAFE. It does not make it VISIBLE:
+ * afterwards an oversight and a deliberate production deployment produce
+ * byte-identical reports. The second half of the ruling — this file's subject —
+ * asked doctor to say the default out loud instead of leaving it documented.
+ *
+ * ── What this file pins, and what it deliberately does not ───────────────
+ *
+ * It pins the ROW: that it exists exactly when the variable is unset, that it
+ * says both halves of the sentence ("treated as production" + "set it
+ * explicitly"), and that it is a `warning` rather than an `error`. The severity
+ * is load-bearing and not cosmetic: doctor's display loop derives `hasErrors`
+ * from this field and `hasErrors` is what calls `process.exit(1)`. An unset
+ * `NODE_ENV` must not fail anyone's health check.
+ *
+ * It does NOT re-pin `doctorNodeEnv()`'s own derivation — that is
+ * `doctor-env-provenance.test.ts`'s subject and was already correct before
+ * #5673 (it is the alignment TARGET, not a thing this issue changed). What is
+ * pinned here instead is the CORRESPONDENCE: the row appears exactly when
+ * `doctorNodeEnv()` fell back to its default, so the row cannot start claiming
+ * a default that was not taken.
+ *
+ * The `/discovery` half of the same ruling lives in
+ * `packages/runtime/src/discovery-schema-conformance.test.ts`.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import Doctor, { nodeEnvCheck, doctorNodeEnv } from './doctor.js';
+
+/** `packages/cli` — the oclif root the real command is loaded against below. */
+const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+/**
+ * `chalk` may or may not emit SGR codes depending on TTY detection.
+ *
+ * The escape is written as `\x1b`, never as the byte itself: one raw control
+ * character makes grep treat the whole file as binary, and a test file no
+ * `git grep` can find stops being maintained (#4890 / #5157).
+ */
+const SGR = /\x1b\[[0-9;]*m/g;
+const plain = (s: string) => s.replace(SGR, '');
+
+describe('[#5673] nodeEnvCheck — the row, and when it exists', () => {
+ it('produces a finding when NODE_ENV is unset', () => {
+ const finding = nodeEnvCheck({} as NodeJS.ProcessEnv);
+ expect(finding).toBeDefined();
+ expect(finding!.name).toBe('NODE_ENV');
+ });
+
+ it('produces NOTHING for every environment that set the variable', () => {
+ for (const value of ['production', 'development', 'test', 'staging', 'sandbox', 'qa']) {
+ expect(nodeEnvCheck({ NODE_ENV: value } as NodeJS.ProcessEnv), value).toBeUndefined();
+ }
+ });
+
+ it("treats `NODE_ENV=` as unset — the same collapse doctorNodeEnv() makes", () => {
+ // `doctorNodeEnv({ NODE_ENV: '' })` is already `production` (pinned in
+ // doctor-env-provenance.test.ts). If this check disagreed, doctor would
+ // resolve the cascade for a default it then refused to mention.
+ expect(nodeEnvCheck({ NODE_ENV: '' } as NodeJS.ProcessEnv)).toBeDefined();
+ });
+
+ it('appears exactly when doctorNodeEnv() fell back to its default', () => {
+ // The correspondence, not a restatement of either function. The row claims
+ // "a default was taken"; this is the assertion that the claim is true for
+ // every input, including the ones where NODE_ENV is set to the default's own
+ // value (`production` — set explicitly, so no default was taken, so no row).
+ const cases: Array = [
+ {},
+ { NODE_ENV: '' },
+ { NODE_ENV: 'production' },
+ { NODE_ENV: 'development' },
+ { NODE_ENV: 'test' },
+ ] as NodeJS.ProcessEnv[];
+
+ for (const env of cases) {
+ const defaulted = !env.NODE_ENV;
+ expect(nodeEnvCheck(env) !== undefined, JSON.stringify(env)).toBe(defaulted);
+ // …and when it was taken, the value taken really is `production`.
+ if (defaulted) expect(doctorNodeEnv(env)).toBe('production');
+ }
+ });
+
+ it('is a WARNING — the severity that leaves doctor exiting 0', () => {
+ // `status` is what doctor's display loop turns into `hasErrors` /
+ // `hasWarnings`, and `hasErrors` is the only path to `process.exit(1)`.
+ // Nothing here is broken: the environment starts, in the mode the row names.
+ expect(nodeEnvCheck({} as NodeJS.ProcessEnv)!.status).toBe('warning');
+ });
+
+ it('says BOTH halves: what is happening now, and what to do about it', () => {
+ const finding = nodeEnvCheck({} as NodeJS.ProcessEnv)!;
+ const text = plain(`${finding.message}\n${finding.fix ?? ''}`);
+
+ // Half one — the fact. Without this the row is a nag with no content.
+ expect(finding.message).toContain('production');
+ expect(text).toMatch(/not set/i);
+
+ // Half two — the way out, spelled as the two commands an operator can type.
+ expect(text).toContain('NODE_ENV=production');
+ expect(text).toContain('NODE_ENV=development');
+
+ // …and the one thing a reader would otherwise get wrong: this variable
+ // cannot be supplied by a `.env*` file, because it selects which of them
+ // load. Doctor's whole environment block is about `.env*` provenance, so
+ // omitting this invites exactly the wrong fix.
+ expect(text).toContain('.env*');
+ });
+});
+
+describe('[#5673] os doctor, end to end — the row reaches the report', () => {
+ /**
+ * `node_modules/` exists in the temp cwd on purpose — without it doctor's
+ * `Dependencies` check is itself an `error` and exits 1 on its own, which
+ * would make these assertions pass (or fail) for a reason having nothing to
+ * do with this change. Same trap PR #5390 wrote down.
+ */
+ let tmp: string;
+ let cwdSpy: ReturnType;
+ let savedNodeEnv: string | undefined;
+
+ beforeEach(() => {
+ savedNodeEnv = process.env.NODE_ENV;
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5673-'));
+ fs.mkdirSync(path.join(tmp, 'node_modules'));
+ cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp);
+ });
+
+ afterEach(() => {
+ if (savedNodeEnv === undefined) delete process.env.NODE_ENV;
+ else process.env.NODE_ENV = savedNodeEnv;
+ cwdSpy.mockRestore();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ });
+
+ async function runDoctor(argv: string[] = []): Promise<{ out: string; exitCode: number | undefined }> {
+ const logs: string[] = [];
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => {
+ logs.push(a.join(' '));
+ });
+ let exitCode: number | undefined;
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
+ exitCode = code;
+ throw new Error(`__PROCESS_EXIT__:${code}`);
+ }) as never);
+
+ try {
+ await Doctor.run(argv, { root: CLI_ROOT });
+ } catch (err) {
+ if (!(err instanceof Error) || !err.message.startsWith('__PROCESS_EXIT__')) throw err;
+ } finally {
+ logSpy.mockRestore();
+ exitSpy.mockRestore();
+ }
+ return { out: plain(logs.join('\n')), exitCode };
+ }
+
+ // A real `Doctor.run()` takes seconds (it shells out to `git --version`,
+ // walks the workspace and loads config), so every case here carries an
+ // explicit timeout instead of racing vitest's 5s default.
+ const E2E_TIMEOUT = 60_000;
+
+ it('prints the row when NODE_ENV is unset, and stays exit 0', async () => {
+ delete process.env.NODE_ENV;
+
+ const run = await runDoctor();
+
+ expect(run.out).toContain('NODE_ENV');
+ expect(run.out).toContain('treated as production');
+ // A warning, so the run still calls the environment functional and never
+ // reaches `process.exit(1)`.
+ expect(run.exitCode).toBeUndefined();
+ expect(run.out).toContain('Environment is functional');
+ }, E2E_TIMEOUT);
+
+ // Both directions, because "no row" has to hold for the environment that
+ // agrees with the default as well as for the one that contradicts it: the row
+ // reports that a DEFAULT was taken, not that the mode is production.
+ it.each(['development', 'production'])(
+ 'says nothing about NODE_ENV once the operator set it (NODE_ENV=%s)',
+ async (value) => {
+ process.env.NODE_ENV = value;
+
+ const run = await runDoctor();
+
+ // The whole sentence is absent, not merely softened: a configured
+ // environment's report is what it was before #5673.
+ expect(run.out).not.toContain('treated as production');
+ expect(run.exitCode).toBeUndefined();
+ },
+ E2E_TIMEOUT,
+ );
+
+ it('routes through the shared renderer — `fix` detail appears only under --verbose', async () => {
+ delete process.env.NODE_ENV;
+
+ // #5403's rule: a warning's detail is optional reading, an error's is not.
+ // This row is a warning, so its `fix` must be hidden until asked for — the
+ // proof that it went through `renderHealthCheckResult` rather than growing
+ // a second, flagless format of its own.
+ const quiet = await runDoctor();
+ expect(quiet.out).not.toContain('NODE_ENV=development');
+
+ const verbose = await runDoctor(['--verbose']);
+ expect(verbose.out).toContain('NODE_ENV=development');
+ expect(verbose.out).toContain('NODE_ENV=production');
+ }, E2E_TIMEOUT);
+});
diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts
index 4ed26843c2..4f3da9bee0 100644
--- a/packages/cli/src/commands/doctor.ts
+++ b/packages/cli/src/commands/doctor.ts
@@ -159,6 +159,70 @@ export function doctorNodeEnv(env: NodeJS.ProcessEnv = process.env): string {
return env.NODE_ENV || 'production';
}
+/**
+ * Say out loud that `NODE_ENV` is unset — and that the whole stack is therefore
+ * treating this environment as **production** (#5673).
+ *
+ * `undefined` when the variable is set, so a configured environment gets no row
+ * at all. That is the same shape the tenancy-posture finding has: a value doctor
+ * is happy with is not a finding, and doctor's output for every explicitly
+ * configured environment is unchanged by this addition.
+ *
+ * ── Why the unset case deserves a row ────────────────────────────────────
+ *
+ * `production` is the conservative default and, since #5673, every reader
+ * agrees on it: `os start` forces `NODE_ENV='production'` when unset
+ * (`start.ts:248`), `os serve` resolves its `.env*` cascade for
+ * `NODE_ENV || 'production'` (`serve.ts:532-533`), {@link doctorNodeEnv} is the
+ * same expression, and the `/discovery` `environment` field now advertises
+ * `production` too (`packages/runtime/src/http-dispatcher.ts`).
+ *
+ * Agreement is what makes the default SAFE. It is not what makes it VISIBLE.
+ * An operator who never set the variable cannot tell an intended production
+ * deployment from an oversight, and those two want opposite follow-ups — one is
+ * finished, the other is a local shell about to be told it is production. The
+ * maintainer's 2026-08-06 ruling on #5673 asked for exactly this: the default
+ * state should be loud, not merely documented.
+ *
+ * A `warning`, never an `error`. Nothing is broken: the environment starts, and
+ * it starts in the mode this row names. `error` is what turns doctor's summary
+ * into `process.exit(1)`, and an unset `NODE_ENV` must not fail a health check.
+ *
+ * ── Deliberately NOT in `DOCTOR_ENV_INPUTS`, and not read through the overlay ─
+ *
+ * That list is for variables whose value doctor resolves through the `.env*`
+ * cascade. `NODE_ENV` is the one variable that cannot come from a file: it
+ * SELECTS the cascade (`.env.production` vs `.env.development`), so `os serve`
+ * reads it from the process before any file is loaded and a `NODE_ENV=` line
+ * inside a `.env` never reaches this decision. Attributing it to a file would
+ * report something the runtime does not do — see {@link doctorNodeEnv}'s note.
+ *
+ * "Unset" here is `!env.NODE_ENV`, character for character the condition under
+ * which {@link doctorNodeEnv} falls back to its default. The row therefore
+ * appears exactly when the default was taken, which is the only claim it makes.
+ */
+export function nodeEnvCheck(env: NodeJS.ProcessEnv = process.env): HealthCheckResult | undefined {
+ if (env.NODE_ENV) return undefined;
+
+ return {
+ name: 'NODE_ENV',
+ status: 'warning',
+ message: 'Not set — this environment is being treated as production',
+ fix:
+ 'Set it explicitly so the mode is a decision rather than a default:\n'
+ + ' • production deployment → NODE_ENV=production (what `os start` already forces)\n'
+ + ' • local development → NODE_ENV=development (what `os dev` already sets)\n'
+ + ' Unset reads as production everywhere: `os serve` and `os doctor` resolve the\n'
+ + ' `.env*` cascade for node_env=production, and the /discovery `environment` field\n'
+ + ' advertises "production" (#5673). That is the safe direction — a client asking\n'
+ + ' "am I talking to production?" is never told "development" by an omission — but\n'
+ + ' it also makes an oversight look identical to a deliberate production deployment,\n'
+ + ' and this row is the only place the difference is visible.\n'
+ + ' NODE_ENV cannot be supplied by a `.env*` file: it SELECTS which of those files\n'
+ + ' load, so it is read from the process before any of them.',
+ };
+}
+
/**
* Read — without loading — the `.env*` files `os serve` would load from `cwd`.
*
@@ -1560,6 +1624,16 @@ export default class Doctor extends Command {
// change every such verdict has four possible sources.
results.push(environmentSourcesCheck(dotenvReading));
+ // #5673 — the mode the row above was resolved FOR, when nobody chose it.
+ // Placed immediately after the sources row because it answers the question
+ // that row raises: `node_env=production` appears there whether the operator
+ // set NODE_ENV or not, and only this row tells the two apart. Only the unset
+ // case produces a row; a configured environment's report is unchanged.
+ const nodeEnvFinding = nodeEnvCheck();
+ if (nodeEnvFinding) {
+ results.push(nodeEnvFinding);
+ }
+
// #5382 — the posture verdict resolved at the top of `run()`, reported here
// among the other environment facts. Only an unrecognized value produces a
// row: a valid posture is not a finding, and doctor's output for every
diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts
index 3dd104b5c5..429a87cc3c 100644
--- a/packages/runtime/src/discovery-schema-conformance.test.ts
+++ b/packages/runtime/src/discovery-schema-conformance.test.ts
@@ -272,6 +272,50 @@ describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => {
// …and the whole body still satisfies the schema with that value in place.
expect(DiscoverySchema.safeParse(info).success).toBe(true);
});
+
+ // [#5673] The pin for this issue, and the reason it is driven through the
+ // REAL producer rather than through `resolveDiscoveryEnvironment` alone:
+ // the UNSET default is decided at THIS call site (`getEnv`'s second
+ // argument), so a green mapper test in `packages/spec` cannot see it. The
+ // whole setup is deleting the variable — that is precisely the state of a
+ // production deployment whose operator never set it.
+ //
+ // Reverse verification, direction predicted BEFORE running: restore the old
+ // `getEnv('NODE_ENV', 'development')` and these two cases go RED (they read
+ // `development`), while every `it.each` row above stays green — the old
+ // default was only ever consulted when NODE_ENV was absent, so nothing that
+ // sets it can detect the change. Measured both ways.
+ it.each([
+ ['unset', undefined],
+ // `getEnv` collapses `''` to its default (`process.env[key] || default`),
+ // so `NODE_ENV=` is the same absence as never exporting it — and the same
+ // absence `doctorNodeEnv()` and `os serve` already read as production.
+ ['empty', ''],
+ ])('NODE_ENV %s advertises production — never development (#5673)', async (_label, raw) => {
+ if (raw === undefined) delete process.env.NODE_ENV;
+ else process.env.NODE_ENV = raw;
+
+ const info: any = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ expect(info.environment).toBe('production');
+ expect(DiscoverySchema.safeParse(info).success).toBe(true);
+ });
+
+ // [#5673] The other half, stated as the invariant it is: absence claims
+ // production, a spelling this repo does not recognise never does. These are
+ // two different rules and #5673 deliberately moved only the first — #4828's
+ // "never CLAIM production on a guess" is untouched, and this case is the
+ // guard against a later simplification collapsing them back into one.
+ it.each(['qa', 'preview', 'uat', 'nonsense'])(
+ 'NODE_ENV=%s is an unrecognised spelling — still development, never production (#4828)',
+ async (raw) => {
+ process.env.NODE_ENV = raw;
+
+ const info: any = await dispatcher.getDiscoveryInfo('/api/v1');
+
+ expect(info.environment).toBe('development');
+ },
+ );
});
it('emits the canonical `name`, never the deprecated `apiName` alias', async () => {
diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts
index aa5f500780..711922d078 100644
--- a/packages/runtime/src/http-dispatcher.ts
+++ b/packages/runtime/src/http-dispatcher.ts
@@ -1291,9 +1291,44 @@ export class HttpDispatcher {
// `getEnv('NODE_ENV', 'development')` raw — so `NODE_ENV=test` (what
// vitest sets) or `staging` advertised a value outside the declared
// enum on a machine-readable surface. The mapping table and the
- // reasoning per row live with the enum, in `@objectstack/spec/api`,
- // so both discovery producers answer identically.
- environment: resolveDiscoveryEnvironment(getEnv('NODE_ENV', 'development')),
+ // reasoning per row live with the enum, in `@objectstack/spec/api`.
+ //
+ // [#5673] The DEFAULT — what this producer says when the host set no
+ // `NODE_ENV` at all — flipped from `development` to `production` per
+ // the maintainer's 2026-08-06 ruling. Two facts made the old default
+ // the wrong one:
+ //
+ // • Every other reader of the same absence already said
+ // `production`. `os start` forces `NODE_ENV='production'` when
+ // unset (`packages/cli/src/commands/start.ts:248`), `os serve`
+ // resolves its `.env*` cascade for `NODE_ENV || 'production'`
+ // (`serve.ts:532-533`), and `os doctor` derives the identical
+ // expression (`doctor.ts` `doctorNodeEnv()`). Discovery was the
+ // one surface reading that absence the other way.
+ // • `environment` is a MACHINE-READABLE field: a client reads it to
+ // answer "am I talking to production?" and may skip production
+ // warnings or loosen a destructive action's confirmation on the
+ // answer. Of the two ways to be wrong here, claiming
+ // `development` on a real production deployment whose operator
+ // forgot the variable is the dangerous one.
+ //
+ // #4828's rule is untouched and is a DIFFERENT rule: a value that IS
+ // set but is not a spelling this repo recognises (`qa`, `preview`)
+ // still degrades to `development` inside the mapper, so nothing here
+ // ever CLAIMS production on a guess. Absence is not a guess — it is
+ // the host declining to say, and the conservative answer to that is
+ // `production`.
+ //
+ // The default is passed as `getEnv`'s second argument rather than
+ // moved into `resolveDiscoveryEnvironment` because the mapper lives
+ // in `@objectstack/spec`, which this issue's ruling put out of scope.
+ // Consequence, stated rather than hidden: the second discovery
+ // producer (`getDiscovery()` in `@objectstack/metadata-protocol`,
+ // served by `@objectstack/rest`) passes a genuinely-absent
+ // `NODE_ENV` straight into the mapper and therefore still answers
+ // `development` for the unset case. Filed as a follow-up (#5936);
+ // do not "fix" it by re-defaulting a consumer somewhere else.
+ environment: resolveDiscoveryEnvironment(getEnv('NODE_ENV', 'production')),
routes,
// [#4828] `endpoints` (a verbatim duplicate of `routes`, commented
// "Alias for backward compatibility with some clients") and the