Skip to content

Commit 769adff

Browse files
feat(metadata-protocol)!: thread the installed-manifest lookup through every pair site; the ensure door creates no pair for a kernel package (#31060)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoF7zm6g9Ti1PzWyHemzLqez)_ Clause-②: yes (narrowing) — both arms. Narrowing: `POST /api/v1/packages/:id/ensure-customization` answers a kernel base (scope `system` / `cloud`) with `409 RESOURCE_CONFLICT`, where it created `<id>.custom`; `POST /api/v1/automation/:name/clone` of a kernel-shipped flow with no `package` named answers that `409` where it answered `200` with the copy in `<id>.custom`; the exported pair helpers take the installed-manifest lookup as a required argument. Widening: five new exports, `installedManifestsOf`, `InstalledManifestLookup`, `AppLookupManifest` and `InstalledPackageSource` (`@objectstack/metadata-core`) and `kernelPackageScope` (`@objectstack/metadata-protocol`), and a copy of a kernel-shipped permission set with no `package` named is saved environment-wide under the new notice code `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`, where it landed in `<kernel id>.custom`. Metadata-protocol: none Refs #29594 Ruling-ref: 6691022506 Ruling-ref: 6691217245 ## 维护者速读 - **改了什么**:①所有「这个包的定制包是谁」的问题(`pairOf` / `isPairOf` / `isServedOverrideRowOf` / 写闸 / flow 优先级 等 23 处、14 个文件,连同 cli 与 cloud-connection 的调用方)都改为携带「已安装清单查询」,`appOf` 仍是恒等,行为不变;②ensure 门对内核包(scope `system` / `cloud`)返回 409 `RESOURCE_CONFLICT`,不再创建 `<id>.custom`。 - **为什么改**:按裁定 6691022506(定制单位 = App)与 6691217245(本版本内核对象一律不可定制,ensure 门给可读的拒绝而非 422)。查询先穿好,PR 3 才能只改 `appOf` 一处把模块解析到它的 App。 - **风险与回滚**:已在某些部署上由旧门建好的内核配对包继续安装、继续被读取、继续可写,只拒绝 ensure 调用;内核条目的无包写入与指名内核包的写入仍按 D11 拒绝(403 NOT_OVERRIDABLE),提示语改为「本版本内核对象不可定制」,不再点名配对包;克隆平台权限集(如 member_default)且未指定包时,副本改存为环境级(维护者已选 A,2026-11-07T01:01Z)。回滚即 revert 本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-customization-package.ts`(内核判定)、`packages/metadata-core/src/customization-pair-lookup.ts`(查询入口)、`packages/objectql/src/protocol-custom-override-effective-read.test.ts` 末尾两条钉子。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run --project local src/domains/packages-ensure-customization.test.ts`(内核 409、旧配对包保留);`pnpm --filter @objectstack/objectql exec vitest run --project local src/protocol-custom-override-effective-read.test.ts`(旧内核配对包的覆盖继续生效;两道关闭对内核条目仍拒绝且不点名配对包)。 - **需要你做的**:无,席位落地。PR 3(`appOf` 翻转、旧配对包容忍、ensure 门回答 App 配对包、HotCRM 演练)在本 PR 合并且 ADR-0146 修订 #31027 合并后开工。 ## What changes ### 1. The installed-manifest lookup, threaded through every pair site (behaviour unchanged) `pairOf(base) = customizationPackageId(appOf(base))` landed in #31026 with `appOf` the identity. Resolving a module to its App (ADR-0146 amendment, merge question 11, #31027) needs the registry's installed manifests (`type` / `dependencies` / `namespace` / `scope`) at **every** site that asks "which pair customizes this package?". If only the ensure door resolved the App, the override gate and the served-override read (still identity) would refuse or hide the very override written into the App pair. So this PR threads the lookup everywhere first, with `appOf` still the identity, and PR 3 becomes a one-place flip. - `@objectstack/metadata-core`: `InstalledManifestLookup`, `installedManifestsOf(registry)`; `appOf`, `pairOf`, `isPairOf` take the lookup (required, so the compiler names every site that has not passed it). - `@objectstack/metadata-protocol`: `installedManifestsOfEngine(engine)`; `isServedOverrideRowOf(row, base, installed)`, `OverrideWriteClosure.installed`, the overlay merge, the lock state, the layered/list reads, the per-package layer, the stored-flow slot, the served-overrides census, the package-less migration census, the packaged-base refusal and the write gates. Hosts that read the engine gain the `engine` getter (the codebase's `readEngine` pattern). - Callers outside it: `objectql` (plugin, registry uninstall refusal), `runtime` (`customizeInPairRemedy`, the per-package customizations read), `service-automation` (flow precedence, with the objectql registry), `service-datasource`, `cli` (offline override declarations: the walked manifests), `cloud-connection` (install-local: the delivered manifests over the registry's). - Proof nothing moves: `customization-pair-lookup.test.ts` asks every row twice, with nothing installed and with every row installed (the module declaring its App the way HotCRM's modules do), and gets the old derivation; every existing suite of the touched packages is green with the lookup passed. - Review nit 3 of #31026: `os validate`'s customization-namespace advisory skips a pair through `isCustomizationPackage`. Edge: an id ending in `.custom` that no base derives (`a.custom.custom`, bare `.custom`, `A.custom`) was skipped before, because `customizationPackageId` answers `base_is_customization_package` for it, and is now judged like any package, because `isCustomizationPackage` answers `false` (measured on the head's spec build). ### 2. The kernel verdict on the ensure door (narrowing) | Call | Before | After | |:--|:--|:--| | ensure a kernel base (scope `system` / `cloud`), no pair yet | `201`, creates `<id>.custom` | `409 RESOURCE_CONFLICT`, nothing created or written | | ensure a kernel base whose pair an earlier version created | `200`, `created: false` | `409 RESOURCE_CONFLICT`; the pair stays installed and readable | | every read of a kernel item's name with a declared override in that legacy pair | the override is served | unchanged | | a save into that legacy pair declaring the override | admitted | unchanged (the write gate never asks the ensure door) | | package-less save onto an item a kernel package ships (overlay types) | `403 NOT_OVERRIDABLE`, naming the pair | `403 NOT_OVERRIDABLE`, the sentence says kernel objects are not customizable in this version and names no pair | | save naming the kernel package onto an item it ships | `403 NOT_OVERRIDABLE`, naming the pair | `403 NOT_OVERRIDABLE`, the same sentence, no pair named | | clone a kernel-shipped permission set, no `package` | `200`, copy in `<kernel id>.custom` (created) | `200`, copy environment-wide, code `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE` | | clone a kernel-shipped flow, no `package` | `200`, copy in `<kernel id>.custom` | `409 RESOURCE_CONFLICT`, the ensure door's sentence | | any non-kernel base | — | unchanged | **The code.** `409 RESOURCE_CONFLICT` is the door's existing family for "the base's state rules the pair out" (a writable base, no namespace, a namespace with no pair namespace, a non-SemVer version, a malformed existing pair), every one of which answers `RESOURCE_CONFLICT` in `packages-ensure-customization.test.ts`. `403` would misread as an authority problem (even the platform operator is refused), and `422 NAMESPACE_CONFLICT` names another condition. No ledger code and no `packages/spec` edit. **Where.** First in `customizationPairEnsureRefusal`, the one reading of the door's registry-decidable refusals, and the two overlay closes keep their place: `overlayPairFixOf` asks the door's other judgements with the kernel verdict left out (`judgeKernel: false`) and marks a kernel base's fix with its `kernelScope`, so the package-less close and the close of a write naming the base stay shut on a kernel item exactly where they were, with a sentence that names no pair (seat ruling on review should-fix 2; ruling record 6691217245; #31027 Q10 is unmerged). **The "new similar" doors (found by the dogfood battery).** The permission-set clone door landed a copy of a kernel-shipped set (`member_default`, shipped by `com.objectstack.plugin-security`, scope `system`) in the source package's pair through the ensure door, so the verdict refused the commonest lock-and-clone path. A clone is a new item, not a customization of the kernel item, so with no `package` named the copy is now saved environment-wide, exactly as a copy of an environment-authored set is, with its own notice and code (`PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`; a Console that does not know the code shows the English sentence, as it does for any unknown code). A named `package` still lands there. The kernel predicate is one spelling, `kernelPackageScope` (`@objectstack/metadata-protocol`), read by the door and the clone door. The flow clone door has no environment-wide landing (an unpackaged flow already needs `package`), so a kernel-shipped flow gets the ensure door's 409 sentence. This landing is option A of a decision card put to the maintainer; B (refuse, naming `package`) and C (a kernel pair for clones only) are the alternatives. **Test fixtures.** `packages-ensure-customization.test.ts` and `automation-flow-clone-custom-package.test.ts` modelled "an installed app" as `scope: 'system'`, which is the kernel's scope (manifest schema: `system` = cross-project shared identity, `cloud` = control plane). They now hold the base as code, the way an installed app's packages are held (HotCRM 6961ab1 declares no scope). ## Blast radius (measured on `origin/main` 3fe5f2a) 23 `pairOf` / `isPairOf` sites in 14 files; `isServedOverrideRowOf` 17 non-test callers; `judgeOverrideWrite` 4 (incl. `cli` offline and `cloud-connection` install-local). This PR: 31 source files (+471 / −156), 16 test files (+342 / −123), one changeset; 48 files, +842 / −279 in all. ## PR 2 / PR 3 split - **PR 2 (this):** the lookup through every site, `appOf` the identity; the kernel verdict. - **PR 3 (after this lands and #31027 merges):** `appOf` resolves App membership from the lookup (Q11); legacy per-package pairs stay served (Q12: `isPairOf` accepts the App pair or an installed legacy `<base>.custom`); the ensure door answers the App pair with `basePackageId` = the App id (the spec describe "`packageId`: `<basePackageId>.custom`" stays true); the HotCRM rehearsal. Findings kept for PR 3: the package-less migration's incomplete-declarations census parses pair→base (`customizationBasePackageId`) and misses module overrides under the App unit; its census still lists a kernel item's package-less row as `movable` and the apply step fails it with the kernel sentence (a disposition for it is a contract question); the dogfood helper `customization-pair.ts` and six `ui/packages/app-shell` call sites derive `<base>.custom` client-side; a `customizable` flag on the package list row would need a spec sub-card. A package-less row on a kernel item stored before the D11 close stays served, but nothing can move it now: its package-less delete is closed, and the ceremony's ensure step refuses the kernel base. Its disposition belongs with the census question already listed. ## Verification Head `b94d1ac68d151d99ad908992ad25f65bf2413a6d`, which merges today's `origin/main` 022b4e7 (merge commit 20c8cd6, no conflicts; no rebase). Exit codes landed on disk before reading. | Run | Result | |:--|:--| | `pnpm typecheck` in metadata-protocol, objectql, runtime, service-automation (head) | exit 0 each | | `pnpm test` on the head: metadata-protocol / objectql / runtime / service-automation / metadata-core / lint | exit 0 each: 22512 (+26 skipped) / 11360 / 8876 (+19 skipped) / 3431 / 962 / 8331 passed | | `pnpm test` on `1340933c` for the packages this round did not touch: service-datasource / cloud-connection / cli | exit 0 each: 1279 / 931 / 7415 (+9) passed | | dogfood battery on `021a8a3d` (no dogfood pin moved this round) | exit 0: 428 files passed, 3 skipped; 3562 tests passed | | eslint `--no-inline-config` on every `.ts` file this round changed | exit 0 | | `dispatch-gates --commands` on the head (102 families: the 101 of the last round plus `check:overlay-whitelist-table`, which the lifecycle docs edit adds), each run with `</dev/null` | 102 exit 0 on the first pass; reconciled with `--ran`: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN | Gate notes: the three first-pass non-zeros of the earlier round (`check-plugin-teardown-shape --self-test`, `check:dual-build-cjs-loads`, `check:test-source-alias`) all exit 0 on this head with no further change. **Ablations** (`scripts/ablation-replace.mjs`, restore proven by blob hash == HEAD and an empty `git status --porcelain`): - the kernel check in `customizationPairEnsureRefusal` disabled (`ablation-dist-preflight` confirmed the mutation in `metadata-protocol/dist`): red — the three runtime door pins (system, cloud, the legacy kernel pair) and the objectql pin of the legacy pair served after a refused ensure; - the kernel landing in `resolveDefaultCloneLanding` disabled: red — `[#29594] a set a kernel package ships copies environment-wide`; - the kernel close in `overlayPairFixOf` disabled (`judgeKernel: false` removed, metadata-protocol dist rebuilt and checked): red, `the closes keep refusing a kernel item's save, naming no pair` (the save was admitted); - `restoreFlowRegistration` on a refused landing in `registerAndSaveFlow` disabled: red, both `[#29594] a kernel-shipped flow` cases (the engine held the copy). Both restored with `git checkout`, tree clean, suites re-run green. The lookup threading needs no ablation: it changes no answer, which every existing suite of the nine packages confirms, and `customization-pair-lookup.test.ts` pins that both lookups (nothing installed, every row installed) give the old derivation. ## Acceptance notes - The harness's assignee/reviewer step is skipped: this PR goes through the relay as a draft, and review is requested by the seat. - No `packages/spec`, `ui/` or `skills/` edit; no ADR edit. - 51 files: 31 source files, 16 test files, 3 docs pages (`content/docs/concepts/metadata-lifecycle.mdx` added), 1 changeset. <!-- signed: domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8304b08 commit 769adff

51 files changed

Lines changed: 984 additions & 297 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/runtime': patch
6+
'@objectstack/cli': patch
7+
'@objectstack/cloud-connection': patch
8+
'@objectstack/lint': patch
9+
'@objectstack/service-automation': patch
10+
'@objectstack/service-datasource': patch
11+
---
12+
13+
feat(metadata-protocol, metadata-core)!: the ensure door creates no customization pair for a kernel package, and every pair question carries the installed manifests
14+
15+
Clause-②: yes (narrowing) — both arms. Narrowing: `POST /api/v1/packages/:id/ensure-customization` (and the protocol's `ensureCustomizationPackage`) answers a kernel base, a package of scope `system` or `cloud`, with `409 RESOURCE_CONFLICT` and a sentence saying kernel objects are not customizable in this version, where it created `<id>.custom`; `POST /api/v1/automation/:name/clone` of a flow a kernel package ships, with no `package` named, answers that `409` where it answered `200` with the copy in `<id>.custom`; and the exported `pairOf`, `isPairOf` and `appOf` (`@objectstack/metadata-core`), `isServedOverrideRowOf` and the `installed` member of `OverrideWriteClosure` (`@objectstack/metadata-protocol`), and `resolveFlowPrecedence` / `describeFlowContender` (`@objectstack/service-automation`) take the installed-manifest lookup as a required argument. Widening: five new exports, `installedManifestsOf`, `InstalledManifestLookup`, `AppLookupManifest` and `InstalledPackageSource` (`@objectstack/metadata-core`) and `kernelPackageScope` (`@objectstack/metadata-protocol`), and one new `noticeCode` value on the permission-set clone answer, `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`, for a copy of a kernel-shipped set that now lands environment-wide.
16+
Metadata-protocol: none
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed or re-shaped and no stored row is read differently, converted or dropped: the narrowing is one runtime refusal at one door, for one kind of base, whose remedy is that no pair exists to create; the existing pairs and the overrides in them stay served exactly as before. The retyped exports are runtime functions with no metadata surface, and the compiler names every caller that has not passed the new argument, so there is nothing for `objectstack migrate meta` to rewrite. -->
19+
20+
**BREAKING** for a caller that ensures the customization pair of a kernel package — a platform package of scope `system` (users, organizations, security, settings and the rest of the platform suite) or `cloud` — and for a TypeScript importer of the pair helpers named above.
21+
22+
- **Why (ruling records 6691022506 and 6691217245 on ADR-0146 D3).** The customization unit is the App, and the kernel is not an App a customer owns: it ships with the runtime, at the runtime's version (installing it whole is the goal ruling 6691022506 records, not yet what every `serve` tier does). In this version no kernel object is customizable, so the door creates no pair for one. Which kernel objects a later version opens (a whitelist) is that version's decision.
23+
- **What the door answers.** `409 RESOURCE_CONFLICT`, the status and code the door already answers when a base's state rules the pair out (a writable base, no namespace, a version that is not SemVer). Nothing is created or written. Every other base is answered exactly as before.
24+
- **A kernel pair that already exists stays.** An earlier version of the door may have created `<kernel id>.custom` on a deployment. It stays installed, the overrides inside it stay served by every read of the kernel item's name, and a further save into it is still admitted. Only the ensure call is refused.
25+
- **A copy of a platform permission set.** `POST /api/v1/security/permission-sets/:name/clone` with no `package` named landed the copy of a kernel-shipped set (`member_default` and the platform's other sets) in `<kernel id>.custom`. It now saves the copy environment-wide, as it already does for a set authored in the environment, with the notice code `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE` and a sentence naming the kernel package and `package`. A Console that does not know the new code shows that sentence as sent. Naming a writable package with `package` still lands the copy there. The flow clone door answers a kernel-shipped flow with the ensure door's sentence.
26+
- **The closes stay shut on a kernel item.** A package-less write, or a write naming the kernel package, onto an item a kernel package ships is still refused `403 NOT_OVERRIDABLE` exactly where it was refused before (ADR-0146 D11). The sentence no longer names the pair as the fix, since there is none to ensure: it says kernel objects are not customizable in this version. A later version's kernel whitelist is what opens such an item.
27+
- **The lookup.** Which pair customizes a package is asked through the installed manifests of the registry the caller serves (`installedManifestsOf(registry)`). Today the answer is unchanged for every package: `appOf` is still the identity. The argument is threaded through every site now so that resolving a module to its App is one change in `appOf`.
28+
- **`os validate`'s customization-namespace advisory** now skips a package that is itself a pair through `isCustomizationPackage`, the one spelling of that question. An id ending in `.custom` that no base derives (`a.custom.custom`, a bare `.custom`) is now judged like any other package.
29+
- **Who moves, and the fix.** A script or client that ensured a kernel package's pair drops that call: the kernel item is served as it ships, and an existing pair keeps serving what it holds. A vendor package that declares `scope: 'system'` or `'cloud'` in its own manifest is read as kernel here, as `isWritablePackage` already reads it; its remedy is to drop the scope, which belongs to the platform's own packages. A TypeScript importer of the helpers passes `installedManifestsOf(engine.registry)`, or `installedManifestsOf(undefined)` where it holds no registry.

‎content/docs/api/metadata-api.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ capability changes only with a new version of the package that declares it. An
111111
sentence names that type's own route. What the reads then serve is in
112112
[Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146).
113113
`POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls
114-
it on the first change), and `GET /packages/hotcrm/customizations` lists its overrides,
114+
it on the first change); a kernel package (scope `system` or `cloud`) has no pair in this
115+
version and answers `409 RESOURCE_CONFLICT`, though a pair an earlier version created keeps
116+
serving what it holds. `GET /packages/hotcrm/customizations` lists its overrides,
115117
extensions and switched-off items in one read, beside `contributions`: the fields (kind `field`)
116118
and flows (kind `flow`) other installed packages contribute to `hotcrm`'s objects, and the
117119
navigation items (kind `nav`) they contribute to `hotcrm`'s apps, each entry naming its package.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ document **whole** for every read that serves the base's name
182182

183183
> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and on a SQL database (SQLite, PostgreSQL, MySQL) still holding one a v18 `os serve` refuses to start until the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) has carried such a row to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item.
184184

185-
> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=<base>.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)). A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. So does the package-less write of a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so the ceremony lists the container and leaves it, and its views move into the pair one by one, each as a single view.
185+
> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=<base>.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)); for an item a kernel package (scope `system` or `cloud`) ships, the same write is refused with a sentence that names no pair, because kernel objects are not customizable in this version and a kernel package has no pair. A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. So does the package-less write of a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so the ceremony lists the container and leaves it, and its views move into the pair one by one, each as a single view.
186186

187187
| Type | `allowEnvironmentOverlay` | Rationale |
188188
| :--- | :---: | :--- |
@@ -213,9 +213,9 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
213213

214214
| Type | Sanctioned path the refusal names |
215215
| :--- | :--- |
216-
| `flow` | Declare an override from the base's paired `<base>.custom`, or rename it (a write from any other package is told to rename: only the pair declares an override), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). The declared override is armed in the base flow's place, keeps its one activation switch, and a run suspended on the base finishes on the version it started on (ADR-0146 D9). A copy under a new name is "New similar", the clone door `POST /api/v1/automation/:name/clone` (body `{name, label}`), which the removal refusal still names. A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `<base>.custom` (created on first use), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([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) D3, D4). |
216+
| `flow` | Declare an override from the base's paired `<base>.custom`, or rename it (a write from any other package is told to rename: only the pair declares an override), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). The declared override is armed in the base flow's place, keeps its one activation switch, and a run suspended on the base finishes on the version it started on (ADR-0146 D9). A copy under a new name is "New similar", the clone door `POST /api/v1/automation/:name/clone` (body `{name, label}`), which the removal refusal still names. A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `<base>.custom` (created on first use; a kernel package, of scope `system` or `cloud`, has no pair in this version, so a copy of a flow it ships answers the ensure door's `409` until the body names `package`), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([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) D3, D4). |
217217
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
218-
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `<X>.custom` (ADR-0146 D3/D6). |
218+
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `<X>.custom`, and a copy of a set a kernel package ships lands environment-wide (ADR-0146 D3/D6). |
219219

220220
A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable.
221221

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,11 @@ the source never reach the copy.
453453
package `<X>.custom`, the package that holds everything you changed about X.
454454
The pair is created on the first copy, by the same ensure door as
455455
`POST /api/v1/packages/:id/ensure-customization` (its only creation path),
456-
and reused after. A copy of a set already in a writable package is
456+
and reused after. A copy of a set the platform itself ships (a kernel
457+
package of scope `system` or `cloud`, such as `member_default`) is saved
458+
environment-wide, in no package: a kernel package has no paired
459+
customization package in this version, and the notice says so
460+
(`noticeCode` `PERMISSION_SET_CLONE_ENVIRONMENT_WIDE_KERNEL_SOURCE`). A copy of a set already in a writable package is
457461
saved in that package, and a copy of a set authored in this system stays
458462
environment-wide, in no package. With `package`, the copy is saved there: it
459463
must be an installed, writable package (else `422`

‎packages/cli/src/utils/override-declarations.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { describe, expect, it } from 'vitest';
1515
import { judgeOverrideWrite } from '@objectstack/metadata-protocol';
1616
import { findOverrideDeclarationRefusals } from './override-declarations.js';
17+
import { installedManifestsOf } from '@objectstack/metadata-core';
1718

1819
const BASE = 'com.acme.crm';
1920
const PAIR = 'com.acme.crm.custom';
@@ -33,7 +34,7 @@ const base = pkg(BASE, [], {
3334
});
3435

3536
const judged = (type: string, name: string, packageId: string, item: unknown, dependencies: string[], shippers: string[]) => {
36-
const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers });
37+
const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers, installed: installedManifestsOf(undefined) });
3738
if (v.verdict !== 'refuse') throw new Error(`expected a refusal, got ${v.verdict}`);
3839
return v.message;
3940
};

‎packages/cli/src/utils/override-declarations.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem } from '@objectstack/metadata-protocol';
4040
import { OverrideDeclarationSchema } from '@objectstack/spec/kernel';
41+
import type { InstalledManifestLookup } from '@objectstack/metadata-core';
4142

4243
import { artifactPackages } from './artifact-packages.js';
4344

@@ -56,6 +57,8 @@ export interface OverrideDeclarationRefusalRow {
5657

5758
interface WalkedPackage {
5859
id: string;
60+
/** The package's manifest as the artifact carries it. */
61+
manifest: Record<string, unknown> | undefined;
5962
dependencies: string[];
6063
items: OverrideWriteItem[];
6164
}
@@ -75,14 +78,15 @@ function packagesOf(parsed: Record<string, unknown>): WalkedPackage[] {
7578
if (entries.length > 0) {
7679
return entries.map(({ index, id, body }) => ({
7780
id,
81+
manifest: body,
7882
dependencies: dependencyIds(body),
7983
items: overrideWriteItemsOf(body, `packages[${index}].manifest.`),
8084
}));
8185
}
8286
const manifest = isRecord(parsed.manifest) ? parsed.manifest : undefined;
8387
const id = typeof manifest?.id === 'string' && manifest.id !== '' ? manifest.id : undefined;
8488
if (id === undefined) return [];
85-
return [{ id, dependencies: dependencyIds(manifest), items: overrideWriteItemsOf(parsed, '') }];
89+
return [{ id, manifest, dependencies: dependencyIds(manifest), items: overrideWriteItemsOf(parsed, '') }];
8690
}
8791

8892
/** `id`'s dependency closure, followed through the packages this artifact carries. */
@@ -107,6 +111,9 @@ export function findOverrideDeclarationRefusals(parsed: Record<string, unknown>)
107111
const packages = packagesOf(parsed);
108112
const byId = new Map(packages.map((p) => [p.id, p]));
109113
const shipped = new Map(packages.map((p) => [p.id, new Set(p.items.map((i) => `${i.type}\u0000${i.identity}`))]));
114+
// The manifests this artifact carries, as the pair lookup reads installed
115+
// ones: who may declare an override of a base is its App's pair.
116+
const installed: InstalledManifestLookup = (packageId) => byId.get(packageId)?.manifest;
110117
const rows: OverrideDeclarationRefusalRow[] = [];
111118
for (const pkg of packages) {
112119
const dependencies = closureOf(pkg.id, byId);
@@ -133,7 +140,7 @@ export function findOverrideDeclarationRefusals(parsed: Record<string, unknown>)
133140
(byId.get(baseId)?.items ?? []).filter((i) => i.type === walked.type).map((i) => i.item);
134141
const verdict = judgeOverrideWrite(
135142
{ type: walked.type, name: walked.name, packageId: pkg.id, item: walked.item },
136-
{ dependencies, shippers, baseItemOf, baseItemsOf },
143+
{ dependencies, shippers, baseItemOf, baseItemsOf, installed },
137144
);
138145
if (verdict.verdict !== 'refuse') continue;
139146
rows.push({ path: walked.path, code: 'NOT_OVERRIDABLE', rule: verdict.rule, packageId: pkg.id, message: verdict.message });

0 commit comments

Comments
 (0)