Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f1dfda0
feat(metadata-core): appOf resolves a package to the App that carries it
claude Nov 7, 2026
9ceb1f4
wip: ensure door answers the App pair; reads serve legacy per-package…
claude Nov 7, 2026
fff54e3
wip: widen only a pair that declares its App
claude Nov 7, 2026
84a1ca2
test(runtime): pin the App unit at the ensure door and the HotCRM-sha…
claude Nov 7, 2026
7467e2e
feat: census reads the declared base; docs and dogfood helper follow …
claude Nov 7, 2026
c27d477
fix: uninstall refusal reads the pair named for the package; pin appO…
claude Nov 7, 2026
b358e88
docs(changeset): say what the uninstall refusal reads for an App and …
claude Nov 7, 2026
5ac6ed0
test: lint reads metadata-core from source; the rehearsal pays the me…
claude Nov 7, 2026
016eb45
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 7, 2026
775c97b
docs(changeset): declare the narrowing, the BREAKING banner with its …
claude Nov 7, 2026
c0172dd
fix: a legacy per-package pair removes its own override and its added…
claude Nov 7, 2026
f446e8a
test(runtime): a legacy pair's doc override is served and removed thr…
claude Nov 7, 2026
da5e499
test(runtime): judge the legacy pair's reads and removal on a row an …
claude Nov 7, 2026
787f842
docs(changeset): every reader honours a legacy per-package pair
claude Nov 7, 2026
fb2fd21
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 7, 2026
b1ea4bc
test(runtime): the legacy pair's removal on both topologies; changese…
claude Nov 7, 2026
ef5fdb4
test(runtime): indent
claude Nov 7, 2026
481d404
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/29594-app-customization-pair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/metadata-core': minor
'@objectstack/metadata-protocol': minor
'@objectstack/objectql': minor
'@objectstack/lint': minor
'@objectstack/runtime': minor
---

feat(metadata-core, metadata-protocol, objectql, lint, runtime)!: an App and the modules that declare it share one customization pair

Clause-②: yes (narrowing) — the ensure door refuses two asks it accepted: a package that reaches several Apps and whose namespace picks none of them (was `201` with its own pair, now `409`, merge question 11), and a module whose earlier per-package pair holds the App's pair namespace (was `201`/`200`, now `409 RESOURCE_CONFLICT` naming that pair, merge question 12); `appOf` now returns `string | undefined`. The widening half: a module of an App gets the App's pair (`200`, where it was `422 NAMESPACE_CONFLICT` when the App held the namespace), and `@objectstack/metadata-core` exports `pairsOf`, `AppOfRefusal` and `PairOfRefusal`. Ruling 6691022506; ADR-0146 D3 as amended by #31027.
Metadata-protocol: semantic

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and nothing at rest changes: a pair the ensure door created stays installed and served, and the two new refusals answer an ask without writing anything, so there is nothing for a migration to rewrite. -->

The customization unit is the App (ADR-0146 D3 as amended, ruling record 6691022506). `appOf` in `@objectstack/metadata-core` now reads the installed manifests: a `type: 'app'` package is its own App; any other package's App is the installed `type: 'app'` package it declares in `manifest.dependencies`, directly or through its declared parents (where it reaches several, the one holding its namespace); a package that declares no App is its own unit; a kernel package (scope `system` / `cloud`) keeps its own answer, because no platform pair is created in this version.

What changes for a caller:

- `POST /api/v1/packages/:id/ensure-customization` asked for a module of an App answers the App's one pair, `<app id>.custom` (namespace `<app namespace>_custom`, a dependency on the App at `^<major>`): `201` on the first ask from any member, `200` after, never `422` for a package of the same App. The answer's `basePackageId` is the App. HotCRM: the App and its modules all answer `app.objectstack.hotcrm.custom`.
- `422 NAMESPACE_CONFLICT` stays for the pair namespace held by a package outside the App.
- A per-package pair an earlier version created for a module (`<module id>.custom`) stays installed and honoured as before: its declared overrides are served at the module's address (by name, in the list and in the layered read), its list rows, translations, bundle members it added, object extensions and the served-overrides census are read beside the App's pair (`pairsOf` / `isPairOf`), it may still declare and remove its own overrides of the module's items ("Restore package original" included, for a type with no overlay channel too), and the customizations read (`GET /api/v1/packages/:id/customizations`) does not list it as another package's contribution. No reader is App-pair-only. While it holds the App's pair namespace, the ensure door answers `409 RESOURCE_CONFLICT` naming it instead of creating the App's pair beside it (merge question 12).
- A package that reaches several Apps and whose namespace picks none of them has no pair: the ensure door answers `409` (merge question 11).
- The App's pair's write closure reaches every member of the App, so an override or an object extension of a module's item saved into the App's pair is admitted.
- The uninstall refusal for an installed pair reads the pair named for the package: uninstalling the App is refused while the App's pair is installed, uninstalling a module is not (its customizations are the App pair's), and a module whose own earlier per-package pair is installed is still refused naming that pair.
- `os validate`'s `namespace-no-customization-pair` warning skips a member of a delivered App: its customizations land in the App's pair.

TypeScript: `appOf` now answers `string | undefined` (`undefined` = no single App), and `pairOf`'s refusal union gains `no_single_app` (`PairOfRefusal`); `pairsOf` and the `AppOfRefusal` / `PairOfRefusal` types are new exports of `@objectstack/metadata-core`.

**BREAKING** accept-set narrowing at `POST /api/v1/packages/:id/ensure-customization` (`@objectstack/metadata-protocol`) and a return-type change in `@objectstack/metadata-core`.

- **A package that reaches several Apps and whose namespace picks none.** Before: `201` with its own pair `<package id>.custom`. Now: `409 RESOURCE_CONFLICT` saying the package declares several Apps and its namespace picks no single one of them, and nothing is created. Remedy: declare the one App the package belongs to in `manifest.dependencies`, or give it the namespace of the App it belongs to.
- **A module whose earlier per-package pair holds the App's pair namespace.** Before: the module's own `<module id>.custom` (`201`, then `200`). Now: `409 RESOURCE_CONFLICT` naming that pair, from the App and from every member; the pair stays installed and its customizations stay served. Remedy: keep customizing in the existing pair (its id is in the refusal), or export it (`GET /api/v1/packages/<pair>/export`), delete it, and ensure again to get the App's pair.
- **`appOf` returns `string | undefined`** (was `string`). `undefined` means no single App. Remedy: handle `undefined` (`appOf(id, installed) ?? id` keeps the old reading); `pairOf`'s refusal reason may now also be `'no_single_app'` (`PairOfRefusal`).
3 changes: 2 additions & 1 deletion content/docs/api/metadata-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ capability changes only with a new version of the package that declares it. An
sentence names that type's own route. What the reads then serve is in
[Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146).
`POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls
it on the first change); a kernel package (scope `system` or `cloud`) has no pair in this
it on the first change); asked for a module that declares an App, it answers the App's one
pair (`<app id>.custom`, `200` once it exists); a kernel package (scope `system` or `cloud`) has no pair in this
version and answers `409 RESOURCE_CONFLICT`, though a pair an earlier version created keeps
serving what it holds. `GET /packages/hotcrm/customizations` lists its overrides,
extensions and switched-off items in one read, beside `contributions`: the fields (kind `field`)
Expand Down
9 changes: 8 additions & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,14 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m

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 — for an
base's own items stay read-only. **The unit is the App**: an App and every module or plugin
that declares it (HotCRM's `app.objectstack.hotcrm` and its modules) share **one** pair,
`<app id>.custom` (`app.objectstack.hotcrm.custom`, namespace `<app namespace>_custom`, depending on
the App at `^<major>`), and the ensure door asked for any of them answers that one pair. A package that
declares no App is its own unit, and a kernel package (scope `system` or `cloud`) has no pair in this
version. A per-package pair an earlier version created for a module stays installed and keeps serving
what it holds; while it holds the App's pair namespace, the ensure door answers `409` naming it rather
than create the App's pair beside it. An item of the pair that bears a base item's name — for an
`email_template`, one item per `(name, locale)`, the name **and** a locale the base ships — 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:
Expand Down
4 changes: 3 additions & 1 deletion content/docs/deployment/install-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ the package's paired `<package id>.custom` package
([how](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146)).
`POST /api/v1/packages/<package id>/ensure-customization` creates the pair, or
answers it when it already exists, and a save naming it
(`?package=<package id>.custom`) lands there:
(`?package=<package id>.custom`) lands there. A module that declares its App is
customized in the App's pair, `<app id>.custom`: the same door, asked for the
module, answers that pair (its `packageId`), and a save names it:

- an item the package ships, of a type that takes an override (a `view`,
`dashboard`, `flow` and the other types ADR-0146 lists), is replaced by
Expand Down
1 change: 1 addition & 0 deletions packages/lint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
},
"dependencies": {
"@objectstack/formula": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/sdui-parser": "workspace:*",
"@objectstack/spec": "workspace:*",
"ajv": "^8.20.0",
Expand Down
20 changes: 17 additions & 3 deletions packages/lint/src/validate-customization-namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* customization namespace — so its installers can never customize it — is
* told so while it is still being authored.
*
* Every installed base gets one paired writable package, `<base>.custom`, the
* first time an installer customizes it; its namespace is the base's followed
* Every installed App gets one paired writable package, `<app id>.custom`,
* the first time an installer customizes any of its packages (ADR-0146 D3 as
* amended, ruling record 6691022506); its namespace is the App's followed
* by a suffix, and a base namespace too long for that suffix gets NO pair
* namespace (the #28404 ruling, point 1: a named refusal, no truncation and no
* hashing). The ensure door then refuses to create the pair, so the author of
Expand Down Expand Up @@ -36,6 +37,10 @@
* - A package that is itself a pair (`isCustomizationPackage`, the one
* spelling of "some base derives this id"): a pair is never paired again, and its
* namespace is the pair namespace, longer than a base's by construction.
* - A member of a delivered App (`appOf`, `@objectstack/metadata-core`, read
* over the delivered manifests): its customizations land in the App's pair,
* whose namespace is the App's, so its own namespace has no pair to fit —
* the App's row is judged instead.
* - The helper's other two refusals. A missing namespace is not this rule's
* question, and a namespace the manifest grammar refuses for another reason
* (an uppercase letter, a leading digit) is `os validate`'s refusal, with
Expand All @@ -55,6 +60,7 @@ import {
customizationNamespace,
isCustomizationPackage,
} from '@objectstack/spec/kernel';
import { appOf, installedManifestsOf } from '@objectstack/metadata-core';

import type { AnyRec } from './page-walk.js';

Expand Down Expand Up @@ -104,8 +110,16 @@ export function validateCustomizationNamespace(stack: AnyRec): CustomizationName
const findings: CustomizationNamespaceFinding[] = [];
if (!isRec(stack)) return findings;

for (const { manifest, at } of deliveredManifests(stack)) {
const delivered = deliveredManifests(stack);
const installed = installedManifestsOf({
getPackage: (id: string) => {
const found = delivered.find(({ manifest }) => manifest.id === id);
return found ? { manifest: found.manifest } : undefined;
},
});
for (const { manifest, at } of delivered) {
if (isCustomizationPackage(manifest.id)) continue;
if (typeof manifest.id === 'string' && manifest.id !== '' && appOf(manifest.id, installed) !== manifest.id) continue;

const pairNamespace = customizationNamespace(manifest.namespace);
if (pairNamespace.ok || pairNamespace.reason !== 'base_namespace_too_long') continue;
Expand Down
13 changes: 12 additions & 1 deletion packages/lint/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// This config exists for exactly two settings; everything else stays on
// This config exists for exactly three settings; everything else stays on
// vitest's defaults, deliberately — a key added here re-specifies behaviour
// for every test file in the package (packages/cli/vitest.config.ts's header
// records the incident that taught that). Both settings are package-wide on
// purpose: each removes a mechanism every file here is exposed to, rather
// than tuning one file.
import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
// `@objectstack/metadata-core` is read from source, never its dist/, so a
// unit test here is a verdict about the checkout
// (scripts/check-test-source-alias.mjs). It is the one workspace package
// lint's source imports that is not spec: `appOf`, which the customization
// namespace advisory asks. Anchored, so it cannot swallow a subpath.
resolve: {
alias: [
{ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts') },
],
},
test: {
// A late console.* must not redden a green suite (#10374): vitest's worker
// forwards console output over RPC and discards the promise, and a write
Expand Down
Loading
Loading