diff --git a/.changeset/30119-studio-customization-build-notice.md b/.changeset/30119-studio-customization-build-notice.md
new file mode 100644
index 00000000000..4bbfd3ed11b
--- /dev/null
+++ b/.changeset/30119-studio-customization-build-notice.md
@@ -0,0 +1,12 @@
+---
+'@objectstack/cli': patch
+'create-objectstack': patch
+---
+
+feat(cli,create-objectstack): `os build` says when the local dev database holds Studio customizations the artifact leaves behind; the starter's `AGENTS.md` and README say a Studio change lives in the database and name `os package export`
+
+Clause-②: yes (widening) — the exported CompileCommand gains a protected reportsLocalCustomizations flag (default false); nothing removed or narrowed
+Metadata-protocol: none
+
+- **`@objectstack/cli`: `os build`** ends, on its text face, with one notice when the SQLite database `os dev` opens for the project (the same resolution, over the project's development `.env*` files) holds items of a `.custom` package: how many, in which pair, the database file, and that `os package export` takes them out while `os dev` runs. A change saved in Studio is such an item (ADR-0146 D2), never part of `src/`, so neither the artifact nor the starter's `Dockerfile` carries it. The read is local and read-only; a database that is not a local SQLite file is not probed and prints nothing, and a file that cannot be read is said to be unchecked, never empty. `os compile`, which `os dev` runs on every boot and rebuild, and `os build --json` print nothing new.
+- **`create-objectstack`: the generated `AGENTS.md` and the blank starter's README** say that Studio saves into the environment's database as an item of `.custom`, that `build` and the `Dockerfile` leave it behind, that app changes are made in `src/`, and how a customization moves as a package (`os login`, `os package export`, then `os package install` on the deployment, in the numbered order).
diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts
index df9d3de9452..0450340cbef 100644
--- a/packages/cli/src/commands/build.ts
+++ b/packages/cli/src/commands/build.ts
@@ -4,4 +4,7 @@ import Compile from './compile.js';
export default class Build extends Compile {
static override description = 'Compile ObjectStack configuration to JSON artifact (alias for compile)';
+
+ /** `os build` is what a deployment runs, so it says what the artifact leaves behind. */
+ protected override reportsLocalCustomizations = true;
}
diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts
index 5fadf9a6d07..323cddda7a7 100644
--- a/packages/cli/src/commands/compile.ts
+++ b/packages/cli/src/commands/compile.ts
@@ -87,6 +87,7 @@ import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffi
// [#29051] An undeclared top-level flow cycle — the walk `os validate` step
// 3a-vii runs, over the automation engine's own judge.
import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js';
+import { localCustomizationNotice, readLocalCustomizations } from '../utils/local-customizations.js';
export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
@@ -121,6 +122,13 @@ export default class Compile extends Command {
}),
};
+ /**
+ * Whether the text face ends with the local-customization notice
+ * (`utils/local-customizations.ts`). `os build` turns it on; `os compile`,
+ * which `os dev` runs on every boot and rebuild, keeps it off.
+ */
+ protected reportsLocalCustomizations = false;
+
async run(): Promise {
const { args, flags } = await this.parse(Compile);
const timer = createTimer();
@@ -1447,6 +1455,22 @@ export default class Compile extends Command {
}
console.log('');
+ // A Studio customization in the local dev database is left behind by
+ // this artifact (ADR-0146 D2: it lives in `.custom`, in the
+ // database). Said once, on `os build` only — the command a deployment
+ // runs; `os dev` compiles through `os compile` on every boot and rebuild,
+ // where it would be noise.
+ if (this.reportsLocalCustomizations) {
+ const notice = localCustomizationNotice(
+ await readLocalCustomizations({ projectRoot: process.cwd(), artifactPath }),
+ );
+ if (notice.length > 0) {
+ printWarning(notice[0]);
+ for (const line of notice.slice(1)) console.log(chalk.dim(` ${line}`));
+ console.log('');
+ }
+ }
+
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
diff --git a/packages/cli/src/utils/local-customizations.sqlite.test.ts b/packages/cli/src/utils/local-customizations.sqlite.test.ts
new file mode 100644
index 00000000000..dd520e2e398
--- /dev/null
+++ b/packages/cli/src/utils/local-customizations.sqlite.test.ts
@@ -0,0 +1,97 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// The integration half of `os build`'s local-customization notice: the read
+// against a real SQLite file, the shape `os dev` leaves in
+// `.objectstack/data/objectstack.db` (a `sys_metadata` row per stored item,
+// keyed by `package_id`). The pure half is `local-customizations.test.ts`.
+
+import { describe, it, expect, afterEach } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import Database from 'better-sqlite3';
+// Loaded here, at module top, so no clocked test pays the runtime graph's first
+// load inside its window: `readLocalCustomizations` imports it lazily.
+import '@objectstack/runtime';
+import { readCustomizationsFromSqlite, readLocalCustomizations } from './local-customizations.js';
+
+const dirs: string[] = [];
+function tempProject(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-local-customizations-db-'));
+ dirs.push(dir);
+ return dir;
+}
+afterEach(() => {
+ for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
+});
+
+function devDatabase(root: string, rows: Array<[type: string, name: string, packageId: string | null]>): string {
+ const file = path.join(root, '.objectstack', 'data', 'objectstack.db');
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const db = new Database(file);
+ db.exec('CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT, package_id TEXT, state TEXT)');
+ const insert = db.prepare('INSERT INTO sys_metadata (id, type, name, package_id, state) VALUES (?, ?, ?, ?, ?)');
+ rows.forEach(([type, name, packageId], i) => insert.run(`m${i}`, type, name, packageId, 'active'));
+ db.close();
+ return file;
+}
+
+describe('reading the stored `.custom` items of the dev database', () => {
+ it('counts the items of each `.custom` package, and nothing else', async () => {
+ const root = tempProject();
+ const file = devDatabase(root, [
+ ['view', 'asset_list', 'com.example.asset-repair.custom'],
+ ['dashboard', 'ops', 'com.example.asset-repair.custom'],
+ ['view', 'lead_list', 'com.vendor.crm.custom'],
+ ['view', 'authored', 'com.example.studio-app'],
+ ['view', 'packageless', null],
+ // A package whose id merely contains the suffix is not a pair.
+ ['view', 'decoy', 'com.custom.tools'],
+ // The suffix ends the id; `.customs` is another package.
+ ['view', 'decoy2', 'com.example.customs'],
+ ]);
+
+ const reading = await readLocalCustomizations({ projectRoot: root, env: {} });
+ expect(reading).toEqual({
+ status: 'found',
+ filename: file,
+ packages: [
+ { id: 'com.example.asset-repair.custom', items: 2 },
+ { id: 'com.vendor.crm.custom', items: 1 },
+ ],
+ });
+ });
+
+ it('answers none for a database with no `.custom` item, or not yet booted into tables', async () => {
+ const root = tempProject();
+ const file = devDatabase(root, [['view', 'authored', 'com.example.studio-app']]);
+ expect(await readCustomizationsFromSqlite(file)).toEqual({ status: 'none', filename: file });
+
+ const empty = path.join(tempProject(), 'empty.db');
+ new Database(empty).close();
+ expect(await readCustomizationsFromSqlite(empty)).toEqual({ status: 'none', filename: empty });
+ });
+
+ it('opens the file read-only: it is byte-identical after the read', async () => {
+ const root = tempProject();
+ const file = devDatabase(root, [['view', 'asset_list', 'com.example.app.custom']]);
+ const before = fs.readFileSync(file);
+ await readCustomizationsFromSqlite(file);
+ expect(fs.readFileSync(file).equals(before)).toBe(true);
+ });
+
+ it('answers unknown, with the reason, for a file that is not a database', async () => {
+ const file = path.join(tempProject(), 'objectstack.db');
+ fs.writeFileSync(file, 'not a sqlite database, just text that is long enough to have a header'.repeat(10));
+ const reading = await readCustomizationsFromSqlite(file);
+ expect(reading.status).toBe('unknown');
+ expect(reading).toMatchObject({ filename: file });
+ });
+
+ it('follows OS_DATABASE_URL to the file `os dev` would open', async () => {
+ const root = tempProject();
+ const elsewhere = devDatabase(tempProject(), [['view', 'v', 'com.example.app.custom']]);
+ const reading = await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_URL: `file:${elsewhere}` } });
+ expect(reading).toMatchObject({ status: 'found', filename: elsewhere });
+ });
+});
diff --git a/packages/cli/src/utils/local-customizations.test.ts b/packages/cli/src/utils/local-customizations.test.ts
new file mode 100644
index 00000000000..6daad1ff662
--- /dev/null
+++ b/packages/cli/src/utils/local-customizations.test.ts
@@ -0,0 +1,111 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// The pure half of `os build`'s local-customization notice: which database it
+// reads, which file a URL names, what it says for each reading, and that only
+// `os build` says it. Reading a real SQLite file is the integration half
+// (`local-customizations.sqlite.test.ts`).
+
+import { describe, it, expect, afterEach } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { sqliteFilenameFromUrl } from '@objectstack/runtime';
+import {
+ devDatabaseEnv,
+ localSqliteFile,
+ localCustomizationNotice,
+ readCustomizationsFromSqlite,
+ readLocalCustomizations,
+} from './local-customizations.js';
+import Build from '../commands/build.js';
+import Compile from '../commands/compile.js';
+
+const dirs: string[] = [];
+function tempProject(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-local-customizations-'));
+ dirs.push(dir);
+ return dir;
+}
+afterEach(() => {
+ for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
+});
+
+describe('the database the notice reads is the one `os dev` opens', () => {
+ it('layers the development .env files under the process environment, without loading them', () => {
+ const root = tempProject();
+ fs.writeFileSync(path.join(root, '.env'), 'OS_DATABASE_URL=file:./from-env-file.db\nOS_ONLY_IN_FILE=1\n');
+ fs.writeFileSync(path.join(root, '.env.development'), 'OS_DATABASE_URL=file:./from-development.db\n');
+
+ const fromFiles = devDatabaseEnv(root, {});
+ expect(fromFiles.OS_DATABASE_URL).toBe('file:./from-development.db');
+ expect(fromFiles.OS_ONLY_IN_FILE).toBe('1');
+
+ // dotenv-flow never overrides a variable the process already has.
+ expect(devDatabaseEnv(root, { OS_DATABASE_URL: 'file:/from-shell.db' }).OS_DATABASE_URL).toBe('file:/from-shell.db');
+ expect(process.env.OS_ONLY_IN_FILE).toBeUndefined();
+ });
+
+ it('names a file only for a local SQLite URL', () => {
+ expect(localSqliteFile('file:/tmp/x/objectstack.db', sqliteFilenameFromUrl)).toBe('/tmp/x/objectstack.db');
+ expect(localSqliteFile('wasm-sqlite:///tmp/x/objectstack.db', sqliteFilenameFromUrl)).toBe('/tmp/x/objectstack.db');
+ expect(localSqliteFile('file::memory:', sqliteFilenameFromUrl)).toBeNull();
+ expect(localSqliteFile(':memory:', sqliteFilenameFromUrl)).toBeNull();
+ expect(localSqliteFile('postgres://u:p@db:5432/app', sqliteFilenameFromUrl)).toBeNull();
+ expect(localSqliteFile('libsql://app.turso.io', sqliteFilenameFromUrl)).toBeNull();
+ });
+
+ it('reads nothing when the resolved database is remote or not created yet', async () => {
+ const root = tempProject();
+ expect(await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_URL: 'postgres://u:p@db:5432/app' } }))
+ .toEqual({ status: 'not_applicable' });
+ // The unified default `/.objectstack/data/objectstack.db`, never booted.
+ expect(await readLocalCustomizations({ projectRoot: root, env: {} })).toEqual({ status: 'not_applicable' });
+ expect(await readCustomizationsFromSqlite(path.join(root, 'absent.db'))).toEqual({ status: 'not_applicable' });
+ });
+
+ it('reads nothing, and never throws, for a selection the dev boot refuses', async () => {
+ const root = tempProject();
+ expect(await readLocalCustomizations({ projectRoot: root, env: { OS_DATABASE_DRIVER: 'memory' } }))
+ .toEqual({ status: 'not_applicable' });
+ });
+});
+
+describe('what the notice says', () => {
+ it('names each pair, the count, the file and `os package export`', () => {
+ const lines = localCustomizationNotice({
+ status: 'found',
+ filename: '/p/.objectstack/data/objectstack.db',
+ packages: [
+ { id: 'com.example.app.custom', items: 2 },
+ { id: 'com.vendor.crm.custom', items: 1 },
+ ],
+ });
+ const text = lines.join('\n');
+ expect(text).toContain('3 Studio customizations');
+ expect(text).toContain('com.example.app.custom (2)');
+ expect(text).toContain('com.vendor.crm.custom (1)');
+ expect(text).toContain('/p/.objectstack/data/objectstack.db');
+ expect(text).toContain('os package export');
+ });
+
+ it('says it could not check, never that there is nothing, when the read failed', () => {
+ const text = localCustomizationNotice({ status: 'unknown', filename: '/p/x.db', detail: 'file is not a database' }).join('\n');
+ expect(text).toContain('Could not check /p/x.db');
+ expect(text).toContain('file is not a database');
+ expect(text).toContain('os package export');
+ });
+
+ it('says nothing when there is nothing to leave behind', () => {
+ expect(localCustomizationNotice({ status: 'none', filename: '/p/x.db' })).toEqual([]);
+ expect(localCustomizationNotice({ status: 'not_applicable' })).toEqual([]);
+ });
+});
+
+describe('only `os build` says it', () => {
+ it('turns the notice on for build and leaves it off for compile, which `os dev` runs', () => {
+ const flag = (C: typeof Compile) => (Reflect.construct(C, [[], {}]) as unknown as { reportsLocalCustomizations: boolean })
+ .reportsLocalCustomizations;
+ expect(flag(Build)).toBe(true);
+ expect(flag(Compile)).toBe(false);
+ });
+});
diff --git a/packages/cli/src/utils/local-customizations.ts b/packages/cli/src/utils/local-customizations.ts
new file mode 100644
index 00000000000..dd652bd2e22
--- /dev/null
+++ b/packages/cli/src/utils/local-customizations.ts
@@ -0,0 +1,192 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * "Does the local dev database hold a Studio customization that this build
+ * leaves behind?" — the question `os build` asks after it writes the artifact.
+ *
+ * A change saved in Studio to an item a package ships is an item of that
+ * base's paired customization package `.custom` (ADR-0146 D2, D3),
+ * stored in the environment's database and never in `src/`. `os build` and
+ * the scaffold's `Dockerfile` compile `src/` alone, so a developer who tuned a
+ * view on `os dev` and then deployed lost it with no word said. ADR-0146 D2's
+ * way to move it is the package export (`os package export`), committed and
+ * installed on the target; this module only lets the build say so, once, at
+ * the moment the change is left behind.
+ *
+ * ## Cheap, local, read-only
+ *
+ * No server door and no network round trip: the build reads the SQLite file
+ * `os dev` would open — resolved by the ONE shared resolution
+ * (`resolveProjectDatabaseUrl`, `@objectstack/runtime`), over the environment
+ * `os dev` reads (this process's, with the project's development `.env*` files
+ * under it, as dotenv-flow layers them, and without loading them into this
+ * process) — opened read-only, one aggregate `SELECT` over `sys_metadata`.
+ * A database that is not a local SQLite file (Postgres, MongoDB, libSQL, a
+ * `:memory:` URL) is not probed: reading it would be a network round trip, so
+ * the answer is `not_applicable` and the build prints nothing.
+ *
+ * ## What counts
+ *
+ * Every `sys_metadata` row whose `package_id` ends in the pair suffix
+ * (`CUSTOMIZATION_PACKAGE_SUFFIX`), counted per package. Those rows are what
+ * `os package export` carries in a pair's export; an ensured pair with no item
+ * yet has no row here and nothing to lose, so it is not counted.
+ *
+ * ## Never a verdict it did not reach
+ *
+ * A file that cannot be read, a missing `better-sqlite3` (the optional native
+ * driver), or a database with no `sys_metadata` table yet are told apart:
+ * the first two answer `unknown` with the reason, the last `none` (a database
+ * `os dev` never booted holds no customization). The build never fails on
+ * this probe; `unknown` is printed as such, never as "nothing to export".
+ */
+
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import dotenvFlow from 'dotenv-flow';
+import { CUSTOMIZATION_PACKAGE_SUFFIX } from '@objectstack/spec/kernel';
+
+/** One pair package and how many stored items it holds. */
+export interface LocalCustomizationPackage {
+ id: string;
+ items: number;
+}
+
+export type LocalCustomizationReading =
+ /** No local SQLite file to read: a remote or in-memory database, or none created yet. */
+ | { status: 'not_applicable' }
+ /** Read: no stored item of any `.custom` package. */
+ | { status: 'none'; filename: string }
+ /** Read: these `.custom` packages hold stored items, which the build does not carry. */
+ | { status: 'found'; filename: string; packages: LocalCustomizationPackage[] }
+ /** The file exists but could not be read; `detail` says why. */
+ | { status: 'unknown'; filename: string; detail: string };
+
+/**
+ * The environment `os dev` resolves its database from: this process's
+ * variables over the project's development `.env*` files (dotenv-flow never
+ * overrides a variable the process already has). Nothing is loaded into
+ * `process.env`.
+ */
+export function devDatabaseEnv(
+ projectRoot: string,
+ processEnv: Record = process.env,
+): Record {
+ let fromFiles: Record = {};
+ try {
+ const files = dotenvFlow.listFiles({ node_env: 'development', path: projectRoot });
+ if (files.length > 0) fromFiles = dotenvFlow.parse(files);
+ } catch {
+ // An unreadable `.env*` file: `os dev` loads with `silent: true` and boots
+ // without it, so the build resolves without it too.
+ }
+ return { ...fromFiles, ...processEnv };
+}
+
+/** The SQLite file a resolved database URL names, or `null` when it names none on disk. */
+export function localSqliteFile(
+ url: string,
+ sqliteFilenameFromUrl: (url: string, kind: 'sqlite' | 'sqlite-wasm') => string,
+): string | null {
+ const kind = /^file:/i.test(url) ? 'sqlite' : /^wasm-sqlite:\/\//i.test(url) ? 'sqlite-wasm' : null;
+ if (!kind) return null;
+ let filename: string;
+ try {
+ filename = sqliteFilenameFromUrl(url, kind);
+ } catch {
+ return null;
+ }
+ if (!filename || filename === ':memory:' || filename.startsWith(':')) return null;
+ return filename;
+}
+
+/** The aggregate read: stored items per `.custom` package. */
+export const LOCAL_CUSTOMIZATION_QUERY =
+ "SELECT package_id AS id, COUNT(*) AS items FROM sys_metadata WHERE package_id LIKE ? ESCAPE '\\' "
+ + 'GROUP BY package_id ORDER BY package_id';
+
+/** `%.custom`, with LIKE's own wildcards in the suffix escaped. */
+function suffixPattern(): string {
+ return `%${CUSTOMIZATION_PACKAGE_SUFFIX.replace(/[\\%_]/g, (c) => `\\${c}`)}`;
+}
+
+/** Read the stored `.custom` items of one SQLite file. */
+export async function readCustomizationsFromSqlite(filename: string): Promise {
+ if (!existsSync(filename)) return { status: 'not_applicable' };
+
+ let Database: any;
+ try {
+ Database = (await import('better-sqlite3')).default;
+ } catch (e: any) {
+ return { status: 'unknown', filename, detail: `better-sqlite3 is not loadable (${e?.message ?? e})` };
+ }
+
+ let db: any;
+ try {
+ db = new Database(filename, { readonly: true, fileMustExist: true });
+ const table = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sys_metadata'").get();
+ if (!table) return { status: 'none', filename };
+ const rows = db.prepare(LOCAL_CUSTOMIZATION_QUERY).all(suffixPattern()) as Array<{ id: unknown; items: unknown }>;
+ const packages = rows
+ .filter((r) => typeof r.id === 'string' && r.id.endsWith(CUSTOMIZATION_PACKAGE_SUFFIX))
+ .map((r) => ({ id: r.id as string, items: Number(r.items) }));
+ return packages.length > 0 ? { status: 'found', filename, packages } : { status: 'none', filename };
+ } catch (e: any) {
+ return { status: 'unknown', filename, detail: e?.message ?? String(e) };
+ } finally {
+ try { db?.close(); } catch { /* never opened */ }
+ }
+}
+
+/**
+ * Resolve the database `os dev` opens for `projectRoot` and read its stored
+ * `.custom` items. Never throws.
+ */
+export async function readLocalCustomizations(opts: {
+ projectRoot: string;
+ artifactPath?: string;
+ env?: Record;
+}): Promise {
+ let filename: string | null;
+ try {
+ const { resolveProjectDatabaseUrl, sqliteFilenameFromUrl } = await import('@objectstack/runtime');
+ const { url } = resolveProjectDatabaseUrl({
+ env: opts.env ?? devDatabaseEnv(opts.projectRoot),
+ projectRoot: opts.projectRoot,
+ artifactPath: opts.artifactPath,
+ });
+ filename = localSqliteFile(url, sqliteFilenameFromUrl);
+ } catch {
+ // A database selection the dev boot itself refuses: that boot reports it;
+ // there is no file here to read.
+ return { status: 'not_applicable' };
+ }
+ if (!filename) return { status: 'not_applicable' };
+ return readCustomizationsFromSqlite(path.resolve(filename));
+}
+
+/**
+ * The notice lines for a reading, or `[]` when there is nothing to say. Said
+ * once per build: what is left behind, where it lives, and the one command
+ * that takes it out.
+ */
+export function localCustomizationNotice(reading: LocalCustomizationReading): string[] {
+ if (reading.status === 'found') {
+ const total = reading.packages.reduce((n, p) => n + p.items, 0);
+ const list = reading.packages.map((p) => `${p.id} (${p.items})`).join(', ');
+ return [
+ `This build does not carry ${total} Studio customization${total === 1 ? '' : 's'} stored in your dev database: ${list}.`,
+ total === 1
+ ? `It lives in ${reading.filename}, not in src/, so a deployment of this artifact (or the Dockerfile) does not have it.`
+ : `They live in ${reading.filename}, not in src/, so a deployment of this artifact (or the Dockerfile) does not have them.`,
+ 'Make the change in src/ to ship it with the app, or take it out as a package while `os dev` runs: `os package export`.',
+ ];
+ }
+ if (reading.status === 'unknown') {
+ return [
+ `Could not check ${reading.filename} for Studio customizations this build leaves behind (${reading.detail}).`,
+ 'A change saved in Studio is never in src/; `os package export` takes it out while `os dev` runs.',
+ ];
+ }
+ return [];
+}
diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts
index 770ea80c9cc..4dd6d889aec 100644
--- a/packages/cli/test/validate-build-gate-parity.test.ts
+++ b/packages/cli/test/validate-build-gate-parity.test.ts
@@ -364,6 +364,14 @@ const NOT_A_GATE: Readonly> = {
// classification, for the same reasons.
'Artifact assembly — puts already-collected, already-linted content into the bundle `os build` writes; judges nothing':
['placeCollectedDocs', 'placeSeedFiles', 'placeConnectorSpecFiles'],
+ // `os build`'s local-customization notice (`utils/local-customizations.ts`):
+ // it reads the local dev DATABASE, never the stack, and says how many
+ // Studio customizations (`.custom` rows) the artifact leaves behind.
+ // It refuses nothing and the exit status never depends on it, so it is no
+ // gate in either direction; `os validate` writes no artifact to leave
+ // anything behind.
+ 'An advisory about the local dev database, not the stack — says what the artifact leaves behind; judges no metadata and refuses nothing':
+ ['readLocalCustomizations', 'localCustomizationNotice'],
'Presentation — renders, formats or serialises a verdict something else reached; judges nothing':
[
'printHeader',
diff --git a/packages/create-objectstack/src/studio-customization-disclosure.test.ts b/packages/create-objectstack/src/studio-customization-disclosure.test.ts
new file mode 100644
index 00000000000..e9fbac32b66
--- /dev/null
+++ b/packages/create-objectstack/src/studio-customization-disclosure.test.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
+//
+// A change saved in Studio to an item the app ships is an item of the app's
+// paired `.custom` package, stored in the environment's database (ADR-0146
+// D2) — never in `src/`, so `build` and the Dockerfile leave it behind. The
+// scaffold's two docs a developer and their coding agent read first must say
+// so, name the command that takes it out (`os package export`), and tell the
+// agent to make app changes in `src/`. Both are static files `index.ts`
+// copies, so a source-text pin is what covers them.
+
+import { describe, it, expect } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'templates');
+const AGENTS_MD = fs.readFileSync(path.join(templatesDir, 'AGENTS.md'), 'utf8');
+const README = fs.readFileSync(path.join(templatesDir, 'blank', 'README.md'), 'utf8');
+
+/** The section under the heading that matches `heading`, up to the next heading of that level or higher. */
+function section(markdown: string, heading: RegExp, level: number): string {
+ const lines = markdown.split('\n');
+ const at = lines.findIndex((l) => heading.test(l));
+ if (at === -1) return '';
+ const stop = new RegExp(`^#{1,${level}} `);
+ const end = lines.findIndex((l, i) => i > at && stop.test(l));
+ return lines.slice(at, end === -1 ? undefined : end).join('\n');
+}
+
+describe.each([
+ ['the scaffold AGENTS.md', section(AGENTS_MD, /^## .*Studio/, 2)],
+ ['the blank README', section(README, /^### .*Studio/, 3)],
+])('%s says a Studio change does not travel with the build', (_doc, text) => {
+ it('has the section (vacuity guard)', () => {
+ expect(text.length).toBeGreaterThan(200);
+ });
+
+ it('says where the change lives and that the build leaves it behind', () => {
+ expect(text).toContain('.custom');
+ expect(text).toMatch(/database/);
+ expect(text).toContain('src/');
+ expect(text).toContain('Dockerfile');
+ });
+
+ it('names the command that takes it out, and the install on the target', () => {
+ expect(text).toContain('os package export');
+ expect(text).toContain('os package install');
+ });
+});
+
+describe('the scaffold AGENTS.md tells the agent where app changes go', () => {
+ it('makes app changes in src/, not through Studio', () => {
+ const text = section(AGENTS_MD, /^## .*Studio/, 2);
+ expect(text).toMatch(/\*\*Make every change to the app in `src\/`\*\*, never through Studio/);
+ });
+});
diff --git a/packages/create-objectstack/src/templates/AGENTS.md b/packages/create-objectstack/src/templates/AGENTS.md
index c7d05b059f7..6c4dcbb6c5b 100644
--- a/packages/create-objectstack/src/templates/AGENTS.md
+++ b/packages/create-objectstack/src/templates/AGENTS.md
@@ -80,6 +80,31 @@ under `src/` is never loaded, and `{{PM}} run validate` neither counts nor check
what it holds, until its `index.ts` barrel is imported in `objectstack.config.ts`
and handed to its stack key there.
+## Change the app in `src/` — a Studio change stays in its database
+
+`src/` is what ships: `{{PM}} run build` and the `Dockerfile` compile it, and
+nothing else, into `dist/objectstack.json`. Studio, in the Console that
+`{{PM}} run dev` serves, never writes `src/`. A change saved there to an item
+this app ships (a view, a dashboard, a report) is an item of the package
+`.custom`, stored in that environment's database —
+locally, `.objectstack/data/`. It is not in the build and does not reach a
+deployment.
+
+- **Make every change to the app in `src/`**, never through Studio or the
+ metadata API of a running server: `src/` is what is validated, reviewed and
+ deployed. When a person tried something in Studio and wants it in the app,
+ make the same change in `src/`.
+- **A customization that should stay one moves as a package.** With the dev
+ server running, `npx os login --url http://localhost:3000` once, then
+ `npx os package export --url http://localhost:3000`, writes every package the database
+ holds that `src/` does not register, the `.custom` package among them, to
+ `objectstack-export/NN-ID/objectstack.json`. Commit that directory. After
+ the deployment is up, install each file into it, in the numbered order:
+ `npx os package install objectstack-export/NN-ID/objectstack.json --runtime URL`.
+
+`{{PM}} run build` prints a notice when the local dev database holds such a
+customization.
+
## AI Skills
This project uses the ObjectStack skills catalog from the `@objectstack/skills`
diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md
index b080b43b8c9..a1519919dc1 100644
--- a/packages/create-objectstack/src/templates/blank/README.md
+++ b/packages/create-objectstack/src/templates/blank/README.md
@@ -153,6 +153,27 @@ curl -fsS http://localhost:8080/api/v1/health
Bare Node, Kubernetes, reverse-proxy wiring, and the required secrets are
covered in [Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting).
+### A Studio change is not in the image
+
+The image carries what `src/` builds into, and nothing else. A change you save
+in Studio, in the Console `pnpm dev` serves, is stored in that environment's
+database (locally, `.objectstack/data/`) as an item of the package
+`.custom`. It is never written to `src/`, so
+`pnpm build`, the `Dockerfile` and `docker compose` leave it behind.
+
+- To make it part of the app, make the same change in `src/`: that is what is
+ validated, reviewed and deployed. Ask your coding agent to do it in code, not
+ in Studio.
+- To keep it as a customization, take it out as a package while `pnpm dev`
+ runs: `npx os login --url http://localhost:3000` once, then
+ `npx os package export --url http://localhost:3000`, writes every package the database holds that
+ `src/` does not register, the `.custom` package among them, to
+ `objectstack-export/NN-ID/objectstack.json`. Commit that directory, and once
+ the deployment is up install each file into it in the numbered order with
+ `npx os package install objectstack-export/NN-ID/objectstack.json --runtime URL`.
+
+`pnpm build` prints a notice when your local dev database holds such a change.
+
## Next steps
- Add an object: see the `objectstack-data` skill.