diff --git a/.changeset/28509-custom-override-read-teaching.md b/.changeset/28509-custom-override-read-teaching.md
new file mode 100644
index 00000000000..e267b903c87
--- /dev/null
+++ b/.changeset/28509-custom-override-read-teaching.md
@@ -0,0 +1,12 @@
+---
+'@objectstack/skills': patch
+---
+
+docs(skills): objectstack-platform teaches the one effective read of a `.custom` declared override (ADR-0146 D8)
+
+Clause-②: no
+Metadata-protocol: none
+
+- **Before.** The `overrides: { package, basedOn }` declaration takes effect on every read of a name an installed package ships (ADR-0146 D8), but no published skill mentioned `.custom`, `overrides` or `basedOn`. An agent customizing an installed package (HotCRM) was still taught the Setup overlay row as the only path, which the read now treats as a package-unbound row awaiting migration, or wrote an undeclared same-name item that the layered read reports as an orphan.
+- **Now.** `objectstack-platform`'s Manifest Reference says where a customization lives (the paired `.custom` package), what an author writes (the same-named item with `overrides: { package, basedOn }`, the base's `manifest.id` / `manifest.version`), where the declaration is honoured (only from the pair; today only for a view, dashboard, report, email_template or translation, the other overridable types waiting for their doors; `basedOn` never judged; any other package's declaration ignored) and what the read serves (every read agrees; one item per name in the environment-wide list; orphans in the layered read's `overlay`; a withdrawn public form never re-opened; the pair's export carries the declaration). One eval asserts the declared shape and refuses the Setup-write alternative.
+- The file stays under its token ceiling: the new text is paid for by deleting restatement in the same file (the CLI cheat sheet `references/operations.md` already holds, the seed `object`-is-derived sentence `objectstack-data`'s `seeds.md` holds, a table restating its own paragraph, two doubled rules). The `os migrate` row of `references/operations.md` takes back the `apply` rule the cheat sheet carried (loosens only; `--allow-destructive` for drops/tightenings). The SKILL.md ceiling lowers to its new measurement; no ceiling rises, no frontmatter, schema or behaviour change. The docs site gains the same facts on the metadata-lifecycle page and one sentence on the forms page.
diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx
index ffbd75d5aa2..843ebcd21b3 100644
--- a/content/docs/concepts/metadata-lifecycle.mdx
+++ b/content/docs/concepts/metadata-lifecycle.mdx
@@ -111,6 +111,38 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m
> container of metadata), distinct from the npm `@objectstack/*` packages listed in
> the repo README.
+### Customizing an installed package: the paired `.custom` override (ADR-0146)
+
+A customer's change to an installed base package — HotCRM, id `hotcrm` — is an item of
+the base's paired writable package **`hotcrm.custom`**, which depends on the base; the
+base's own items stay read-only. An item of the pair that bears a base item's name and
+declares in its body `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (the base's
+`manifest.id` and `manifest.version`; `basedOn` is recorded and never judged; schema:
+[Override Declaration](/docs/references/kernel/override-declaration)) replaces the base's
+document **whole** for every read that serves the base's name
+([ADR-0146 D8](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md)):
+
+- **The reads agree.** `GET /meta/:type/:name` with no `?package`, the same read with
+ `?package=hotcrm`, the layered read (`code` = the base's document, `overlay` = the
+ override, `effective` = the override), the list scoped to the base and the
+ environment-wide list all serve the override, stamped `_packageId: 'hotcrm'`. The
+ declaration counts only from the pair — the stored row's own `package_id` must be
+ `hotcrm.custom` — and today only for a `view`, `dashboard`, `report`, `email_template`
+ or `translation`: the rest of `OVERRIDABLE_METADATA_TYPES` (`@objectstack/spec/kernel`)
+ — `page`, `app`, `dataset`, `doc`, `book`, `mapping`, `flow` and `action` — waits for
+ its doors and is not served yet. A declaration from any other package is ignored.
+- **The environment-wide list** serves one item per name that exactly one code package
+ ships. A dependent package's same-named item is not listed beside it and stays under
+ its own `?package=` address. A package-unbound row — the overlay row of the table
+ below — still wins over the base's document until ADR-0146 D11 migrates it into the
+ pair.
+- **Orphans are reported, not dropped.** A row of the base's name stored in the pair
+ without the declaration, or in a package that depends on the base, is reported by the
+ layered read in `overlay` with `effective` = the base's document.
+- **Export carries the declaration.** `GET /packages/hotcrm.custom/export` carries the
+ overriding item with its `overrides` block; `GET /packages/hotcrm/export` carries the
+ base unchanged.
+
## Overlay whitelist (shared-DB tenancy invariant)
**Most metadata types must not be overlaid by an environment** — overlaying a packaged item would break the physical schema or the behaviour its package ships. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowEnvironmentOverlay` in `packages/spec/src/kernel/metadata-plugin.zod.ts` (spelled `allowOrgOverride` before protocol 18; `GET /api/v1/meta/types` serves it per entry as `allowEnvironmentOverlay`).
diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx
index f477cc1707b..1c1f0b57814 100644
--- a/content/docs/ui/forms.mdx
+++ b/content/docs/ui/forms.mdx
@@ -20,7 +20,7 @@ Both modes:
- Honor `?prefill_=` URL params
- Honor `submitBehavior` (thank-you / redirect / continue / next-record) — with **mode-aware defaults** when it is omitted (see [§8](#8-submitbehavior--what-happens-after-submit))
-A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.enabled: true` and `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically. Clearing either switch withdraws the form from every anonymous endpoint.
+A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.enabled: true` and `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically. Clearing either switch withdraws the form from every anonymous endpoint. An override of the form view in the base package's paired `.custom` package ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md)) is the form these endpoints serve, so it can narrow or withdraw anonymous intake — but it never re-opens a form that the base's own document or any other body the endpoints honoured withdrew: the anonymous doors only narrow.
## Architecture at a glance
diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs
index b52b8b38466..b31e701fe4e 100644
--- a/scripts/check-skills-token-ratchet.mjs
+++ b/scripts/check-skills-token-ratchet.mjs
@@ -581,7 +581,7 @@ export const CEILINGS = new Map([
// absorbed and the ceiling moves 5997 -> 6014 (+17), the measured
// remainder only. Pinned AT the measurement.
// ceil(($(wc -c < skills/objectstack-platform/SKILL.md) - 20) / 4) == ceil(24055 / 4)
- ['skills/objectstack-platform/SKILL.md', 6014],
+ ['skills/objectstack-platform/SKILL.md', 6012],
// `skills/objectstack-pm-dispatch/SKILL.md` (9708) and its
// `rules/dev-template.md` (1838) left this table on 2026-09-10 with the files:
// the maintainer deleted the published PM skill whole (verbatim:
@@ -993,7 +993,7 @@ export const CEILINGS = new Map([
// 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」.
['skills/objectstack-data/references/seeds.md', 1566],
['skills/objectstack-platform/references/bootstrap.md', 1351],
- ['skills/objectstack-platform/references/operations.md', 3125],
+ ['skills/objectstack-platform/references/operations.md', 3119],
['skills/objectstack-platform/references/plugin-development.md', 3137],
['skills/objectstack-upgrade/references/examples-upgrade.md', 1197],
diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md
index c9f6588cae7..bc3d05b51b6 100644
--- a/skills/objectstack-platform/SKILL.md
+++ b/skills/objectstack-platform/SKILL.md
@@ -104,18 +104,6 @@ objectstack compile
OS_ARTIFACT_PATH=./dist/objectstack.json objectstack dev
```
-Runtime rule of thumb:
-
-| Context | Metadata source | Database role |
-|:--------|:----------------|:--------------|
-| Local dev | TS files or `dist/objectstack.json` | Business rows only |
-| Production runtime | Artifact API response | Business rows only |
-| Control plane | Published JSON in metadata storage | Environment revisions, history, overlays |
-
-When generating `objectstack.config.ts`, keep object names short and
-`snake_case`; never set `tableName`, and do not add `sys_metadata` objects to an
-environment runtime manifest.
-
---
## Manifest Reference
@@ -139,10 +127,12 @@ checks it **before it loads anything** and refuses an out-of-range app at the
boundary with the exact migration command. Change it when you deliberately move
to a new protocol major — never to silence a mismatch.
-**Object naming:** The object `name` is the canonical identifier and equals the physical table name. Embed any domain prefix directly in the name (e.g. `name: 'crm_account'`); the object-level `namespace` *field* is retired (ADR-0129 D3) and refused at load.
+**Object naming:** The object `name` is the canonical identifier and equals the physical table name. Embed any domain prefix directly in the name (e.g. `name: 'crm_account'`); the object-level `namespace` *field* is retired (ADR-0129 D3) and refused at load. Never set `tableName`, and never add a `sys_metadata` object to a runtime manifest.
**`manifest.namespace` (ADR-0048):** Optional, but **enforced once set**. When a package declares `manifest.namespace: 'crm'`, every `object.name` must start with `crm_` or `defineStack` errors (`validateNamespacePrefix` in `@objectstack/spec`); the legacy `__` double-underscore form is rejected, and `sys_`-prefixed names are platform-reserved and exempt. The namespace is also a package-ownership key — installing two packages that both claim `crm` fails with `NamespaceConflictError` (downgrade to a warning with `OS_METADATA_COLLISION=warn`). `os lint` additionally emits a non-fatal `naming/namespace-prefix` warning for bare-named UI/automation items (app, page, dashboard, flow, action, report, dataset) when a namespace is set.
+**Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template or translation (page, app, dataset, doc, book, mapping, flow and action wait for their doors), never judges `basedOn` and ignores any other package's declaration. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a package-unbound Setup row still wins over the base's until D11. An undeclared pair row or a dependent's row is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`; the base's is unchanged.
+
---
## The App / Platform Boundary
@@ -411,9 +401,7 @@ export const onEnable = async (ctx: { ql: { registerAction: (...a: unknown[]) =>
The stack's `data:` collection is authored with `defineSeed()`, which
**objectstack-data** owns — go there for `externalId` matching, `env:` scoping,
-and which keys are derived. In particular `object` is derived from the object
-definition: never write it by hand, and never hand-write a raw
-`data: [{ object: … }]` literal.
+and which keys are derived.
`mode` (`upsert` default · `insert` · `update` · `ignore` · `replace` ⚠️ **data
loss**) decides what a replay does to rows that already exist —
@@ -450,29 +438,15 @@ needs an account to exist.
## CLI Commands
Daily commands are in [Part 3 — Operations](./references/operations.md#part-3--operations-cli-testing-deployment).
-Cheat sheet for the bootstrap loop:
-
-```bash
-npx create-objectstack my-app
-cd my-app && npm install
-os dev --ui # dev server + Console at /_console/
-os validate # metadata cross-reference checks
-os compile # produce dist/ artifact
-os migrate plan # preview metadata↔DB schema drift (additive sync never alters existing columns)
-os migrate apply # reconcile DB to metadata (loosening only; --allow-destructive for drops/tightenings)
-PORT=8080 os start # production — pin the port explicitly
-```
---
→ Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Complete Working Example.
----
---
→ Part 2 — Plugin Development & Kernel Extension moved verbatim to [references/plugin-development.md](./references/plugin-development.md) (Quick Reference — Detailed Rules · ObjectKernel vs LiteKernel · Plugin Interface — Quick Overview · PluginContext API · Complete Plugin Example · Using Plugins · Testing Plugins · Well-Known Plugin Names & Services · MetadataPlugin Runtime Boundary · Health Monitoring · Feature Flags); its detailed rules stay at [rules/plugin-lifecycle.md](./rules/plugin-lifecycle.md) · [rules/service-registry.md](./rules/service-registry.md) · [references/plugin-hooks.md](./references/plugin-hooks.md).
----
---
→ Part 3 — Operations: CLI, Testing, Deployment moved verbatim to [references/operations.md](./references/operations.md) (Daily-loop commands · Build & runtime · Verify your work · Ports & networking · Data & migrations · Environments & deploy · Shipping a plugin · Testing pattern · Deployment targets · Health & observability · Common ops pitfalls).
diff --git a/skills/objectstack-platform/evals/config-plugins-ops.json b/skills/objectstack-platform/evals/config-plugins-ops.json
index 8664aa018f2..1193b0bf2d5 100644
--- a/skills/objectstack-platform/evals/config-plugins-ops.json
+++ b/skills/objectstack-platform/evals/config-plugins-ops.json
@@ -90,6 +90,16 @@
"must_contain": ["queues:", "assignmentRules:", "defineQueue", "'sharing'"],
"must_not_contain": ["assigned_to", "beforeInsert"]
}
+ },
+ {
+ "id": 10,
+ "prompt": "We installed HotCRM (`hotcrm`, version 3.2.0). Replace its `crm_account_grid` list view with our own columns so every read of that view serves ours, and keep the change exportable to staging.",
+ "expected_output": "The whole view, under the same name `crm_account_grid`, authored in HotCRM's paired writable package `hotcrm.custom` (which depends on `hotcrm`) with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` in its body — the base's `manifest.id` and `manifest.version`, `basedOn` recorded and never judged. Every read of the name (no `?package`, `?package=hotcrm`, the layered read, both lists) then serves it under `_packageId: 'hotcrm'`, and `GET /packages/hotcrm.custom/export` carries it with the declaration. Not an in-place edit of HotCRM's own read-only item, not a package-unbound Setup row (that path wins only until the D11 migration), not an object extension (that adds fields and replaces no view), and no declaration from any other package (ignored).",
+ "files": [],
+ "assertions": {
+ "must_contain": ["hotcrm.custom", "overrides:", "package: 'hotcrm'", "basedOn"],
+ "must_not_contain": ["PUT /api/v1/meta/view/crm_account_grid", "PUT /meta/view/crm_account_grid"]
+ }
}
]
}
diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md
index c3bf776b4a2..0bfca5358c7 100644
--- a/skills/objectstack-platform/references/operations.md
+++ b/skills/objectstack-platform/references/operations.md
@@ -49,10 +49,9 @@ os build # the same three gates, plus emits dist/objectstack.json
3. **Widget-binding integrity (ADR-0021)** — every dashboard widget's
`dataset` / `dimensions` / `values` resolves to a declared dataset/field.
-Both exit non-zero with a located, corrective message; `os build` additionally
-emits the artifact. Use `os validate` as the fast inner-loop check after editing
-metadata and `os build` when you need `dist/`. In a scaffolded project these are
-`npm run validate` / `npm run build`.
+Both exit non-zero with a located, corrective message. Use `os validate` as the
+fast inner-loop check after editing metadata and `os build` when you need
+`dist/`. In a scaffolded project these are `npm run validate` / `npm run build`.
**Rule of thumb: never report a metadata change as done until `os validate`
passes.** (`os lint` is a *separate* style/convention pass — naming, labels,
@@ -93,7 +92,7 @@ Port resolution is the same for `os dev` and `os start` (both spawn `os serve`):
| `os data create` / `get` / `query` / `update` / `delete` | Record-level CRUD against a running server (there is no `os data seed` / `export` / `import` — seed data in the `data:` collection loads automatically at boot) |
| `os diff` | Compare two ObjectStack config files and detect breaking changes |
| `os meta register` | Register (create/update) metadata on a target server (there is no `os meta apply`) |
-| `os migrate plan` / `os migrate apply` | Dry-run / apply physical-DB drift reconciliation from metadata (forward-only — no batch rollback; `os rollback` was removed) |
+| `os migrate plan` / `os migrate apply` | Preview / reconcile DB drift from metadata; `apply` loosens only, `--allow-destructive` for drops/tightenings (forward-only; `os rollback` was removed) |
## Environments & deploy