You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 6e11a92
Browse filesBrowse the repository at this point in the historyBrowse files
feat(runtime)!: the permission-set clone door lands "new similar" in a writable target package, by default the source package's .custom (#28563) (#28636)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGSvgWmGsEvSht2WRwVcj7u)_Fixes#28563
Clause-②: yes (narrowing) — `POST /security/permission-sets/:name/clone`
takes an optional `package` and its answer names the package the copy
landed in (widening). A copy of a set shipped by a read-only package now
lands in that package's paired `<X>.custom`, ensured through the pair's
one creation path, so a source whose package the ensure door refuses (no
namespace, a version that is not SemVer, or the pair's namespace owned
by another package) is refused 409/422 where it was copied package-less
before; a given target that is unknown, read-only or out of reach is
refused.
Metadata-protocol: none
Skills-docs: updated — `content/docs/permissions/permission-sets.mdx`
(the clone section: the `package` key, where a copy lands, the two
refusals, the answer's `package`) and the `permission` row of
`content/docs/concepts/metadata-lifecycle.mdx`; no page under
`skills/**` names the door.
**BREAKING** accept-set narrowing at the permission-set clone door
(`@objectstack/runtime`).
## 维护者速读
- **改了什么**:权限集「复制」接口(`POST
/security/permission-sets/:name/clone`)现在把副本放进一个可写的软件包:不指定 `package`
时,复制某个只读包 X 发布的权限集,副本落在 X 的配对定制包 `X.custom`(第一次复制时经 N-13 的 ensure
门创建,之后复用);源权限集本就在可写包里,就落在那个包;环境里自建的权限集照旧不进任何包。指定 `package`
时,它必须已安装、可写,且其依赖闭包够得着源包和该权限集授权的每个对象,否则拒收(422 / 409),什么都不写。答复多一个
`package` 字段,说明落在哪。
- **为什么改**:卡 #28563(epic #28236 的 N-20a,维护者「方向定为包级覆盖:代码和 Studio
用同一机制,定制都进 .custom。」):以前副本不属于任何包,客户对 HotCRM 的改动导出时会漏掉它。
- **风险与回滚**:收窄只有一种:源包本身无法配对(没有 namespace、版本不是 SemVer、配对 namespace
已被别的包占用)时,以前能复制成「无包」副本,现在按 ensure 门原话拒收。showcase
与平台自带权限集(`member_default` 等)实测都能正常配对落地。回滚即 revert。
- **先看**:`packages/runtime/src/domains/permission-set-clone.ts`(第 6
步落点与保存时的
`packageId`);`packages/runtime/src/domains/new-similar-target.ts`(与
#28149 共用的目标包规则,本 PR
加了对象闭包这一条);`packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts`。
- **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run
test/permission-set-clone-door.dogfood.test.ts`(先按依赖闭包 build)→ 11/11
通过,其中第一条断言副本行的 `package_id` 是 `com.example.showcase.custom`。
- **需要你做的**:无,席位落地(收窄需先过合约评审)。
## What changes
**The door** (`packages/runtime/src/domains/permission-set-clone.ts`).
- Body `{ name, label, package? }`, still closed. An empty or non-string
`package` is 400 `VALIDATION_FAILED` on field `package`, never read as
omitted.
- New step 6, landing, after the name checks and before the save:
- `package` given: the shared rule `newSimilarTargetRefusal` (below)
judges it, asked of the source's package and of every object the set
grants on (`permissionSetObjectNames`: the `objects` keys, the object
half of each `fields` key, each row-level rule's `object`). A refusal
answers through `newSimilarTargetRefusalAnswer`, so `packageId` and
`docs` reach the envelope's details.
- omitted, source shipped by a read-only package X:
`protocol.ensureCustomizationPackage({ basePackageId: X })`, N-13's
body, the pair's only creation path (⛔ no second one). Its refusals keep
their own status and code. A protocol with no ensure verb answers 501,
never a package-less save.
- omitted, source in a writable package: that package (the ensure door
refuses a writable base, whose changes go into it directly).
- omitted, environment-authored source: environment-wide, unchanged.
- The save passes the landing as `saveMetaItem`'s own `packageId`. The
item still drops `packageId` (`PERMISSION_SET_CLONE_DROPPED_KEYS` is
unchanged), so `permissionSetOwnerBindingRefusal` never sees a body key
disagreeing with the row.
- The answer is `{ permissionSet, package, notice }`; `package` is
`null` environment-wide. `permissionSetCloneNotice(landing)` puts the
landing sentence ahead of the unchanged `PERMISSION_SET_CLONE_NOTICE`.
- Order kept: `manage_metadata`, then the walled catalog refusal, both
before any read and before the pair is ensured, so a tenant
administrator under `isolated` / `group` gets the walled sentence and no
pair is created.
**The shared target rule**
(`packages/runtime/src/domains/new-similar-target.ts`). Byte-identical
to #28149's file at `claude/project-thread-sdqbrt@97489b2` (unknown or
read-only → 422 `WRITABLE_PACKAGE_REQUIRED`; a closure that does not
reach the source's package → 409 `RESOURCE_CONFLICT`, through
`resolvePackageClosure`), plus one limb this door needs: an optional
`objects` list, each judged by the save gate's own closure predicate
`narrowObjectsToPackageClosure` (platform/system objects and unpackaged
ones admitted; an object no registry holds left to the save). That is
what judges an environment-authored source given a target, and a
packaged set granting on another package's object. `@objectstack/lint`
joins runtime's dependencies for the two walks (the same line #28149
adds). The flow door passes no `objects` (it has no honest object list).
**Contract.** The route ledger lists the door as `server-only` with no
`requestSchema` (`packages/runtime/src/route-ledger.ts`), so no spec
schema is invented; the row's note now states the body, the landing
rules, both refusals and the answer's `package`. No error code is minted
(both codes are registered). Docs: the two pages named on the
`Skills-docs:` line.
**Gate roster.** `scripts/check-route-envelope.mjs` `DISPATCHER_DOMAINS`
declares `new-similar-target.ts` `{ handBuilt: 0 }` (the gate reds on an
undeclared file in `domains/`); the #28149 thread has the same two
lines.
**Test alias.** `packages/runtime/vitest.config.ts` aliases
`@objectstack/lint/runtime` to `../lint/src/runtime.ts`, byte-identical
to #28149's line, so the rule's tests judge the source
(`check:test-source-alias` reds without it once runtime imports the
subpath).
**System-context census.** The objects limb hands the registered object
to `narrowObjectsToPackageClosure` whole (`{ ...object, name, _packageId
}`) rather than reading `isSystem` off it, which
`check:system-context-census` reads as an elevation read; the predicate
reads the same flags either way.
## Why
Card #28563 (N-20a of epic #28236): a copy made by the clone door
belonged to no package, so a customer's change to a shipped set (for
example HotCRM's) fell out of the package export. The maintainer's
direction on the epic is package-level override, one mechanism for code
and Studio, with customizations in `.custom` (ADR-0146 D3/D6). This door
now lands its copy there, through N-13's ensure door only.
## Tests
- **Red first** (dogfood, against the unchanged base door, runtime built
at base `cd065d36`): 4 of 10 red. The packaged copy landed package-less:
`expected undefined to be 'com.example.showcase.custom'` (twice); the
environment control's answer had no `package` key; the target case (its
fixture ids were then fixed from underscores to hyphens).
- **Green** (dogfood `permission-set-clone-door.dogfood.test.ts`, real
showcase boot): 11/11. Measured there:
- the first copy of a showcase set creates `com.example.showcase.custom`
(404 before, 200 after), the stored `sys_metadata` row's `package_id` is
the pair, its `metadata` has no `packageId`, and the catalog serves the
copy with `packageId` = the pair;
- the second copy reuses it: one pair in `GET /packages`;
- **Wanted 3, measured:** the pair-bound copy is granted through a
position saved by name AND through a direct `sys_user_permission_set`
row (`resolveUserAuthzGrants` lists it; the direct leg reads it absent
first);
- a platform set (`member_default`, shipped by the security plugin's
manifest, namespace `sys`) lands in its own pair;
- a writable target declaring the showcase lands there; the showcase
itself (read-only) and an unknown id are 422
`WRITABLE_PACKAGE_REQUIRED`, a writable target declaring nothing is 409
`RESOURCE_CONFLICT`, each with no catalog entry and no `sys_metadata`
row;
- an environment-authored source still copies with `package: null` and a
row whose `package_id` is null.
- **Unit** (`permission-set-clone.test.ts`, 35/35): the save's
`packageId` and the ensure call per landing; pair reuse; environment
control (save keys exactly as before, no ensure); writable source; given
target in closure, including an environment-authored source; unknown /
code-package / `scope: 'cloud'` targets 422 naming the package;
out-of-closure by the source's package alone (a `crm` set granting only
`sys_user`) and by an object alone (an environment set granting
`account`); a platform-only set reachable from any target; empty
`package` 400; an ensure refusal keeps its status; no ensure verb 501;
walled tenant refused with no ensure call.
- **Ablation** (`node scripts/ablation-replace.mjs`; the runtime suite
imports the door and the rule from `src/`, so no build sits between
mutation and run; each restore read blob == HEAD and an empty `git diff
HEAD`):
- ensure bypassed (`const pair = await
protocol.ensureCustomizationPackage(...)` → a null pair): 3 red (default
landing, pair reuse, ensure refusal);
- objects limb off (`if (request.objects !== undefined &&
request.objects.length > 0) {` → `if (false as boolean) {`): 1 red (the
environment set given an island target);
- source-package limb off (`if (sourcePackageId !== undefined &&
sourcePackageId !== target) {` → `if (false as boolean) {`): the first
run was GREEN, because the only fixture for it also tripped the objects
limb; I added the `crm`-ships-`sys_user`-only case and re-ran: 1 red.
- `pnpm --filter @objectstack/runtime typecheck` exit 0; `pnpm --filter
@objectstack/runtime test` on the final head: Test Files 502 passed
(502), Tests 8170 passed | 19 skipped (8189).
## Acceptance notes
- **Clause-② arm.** The claim expected `yes (widening)` with a narrowing
arm if `CLAUSE2_ARMS` reads it so. It does: one arm only, and a request
that answered 200 now refuses for a package the ensure door cannot pair,
so the line is `yes (narrowing)` with the `**BREAKING**` banner and the
ADR-0087 `not-required (no-migration-prescription)` marker in the
changeset. Not measured on a live package: no booted package in the
showcase or the platform lacks a namespace (the platform sets pair as
`sys_custom`); the refusal path is the ensure door's own, read from its
code.
- **Shared helper (order's coordination line).** Agreed with the #28149
card thread: their file location, signature and codes; this PR carries
their file byte-identical plus the `objects` limb and the route-envelope
roster line. Whichever lands first owns the file; the other merges
`origin/main` and takes it.
- **Default closure.** The default landings (`<X>.custom`, the source's
writable package) are not asked the target rule: the pair depends on X,
and the source already resolved inside its own package. The save into
the package is judged by the save door's closure-narrowed authoring
gate, as every package save is.
- **Overlap.** PR #28554 edits
`packages/metadata-protocol/src/protocol.ts` and
`protocol-packaged-base-refusal.ts`; this diff touches neither.
## Verification
All on head `40a37314` unless named.
- `node scripts/pm/dispatch-gates.mjs --ran … --repo
objectstack-ai/objectstack`: `✓ dispatch-gates --ran: 124 derived
famil(ies) accounted for — 124 run, 0 NOT-MEASURED`. Every family exit
0, `check:type-check-debt` included (`0 raw tsc error(s) total, none
above its recorded number`, under `os-verify-lock`).
- Runtime: typecheck exit 0; tests 502/502 files, 8170 passed | 19
skipped. Unit file `permission-set-clone.test.ts` 35/35.
- Dogfood, after building the runtime at this head: `vitest run
test/permission-set-clone-door.dogfood.test.ts
test/route-ledger-live-mount-parity.dogfood.test.ts` → Tests 1 failed |
18 passed (19). The clone file is 11/11; every one of its cases is an
HTTP call to `POST /api/v1/security/permission-sets/:name/clone` through
the real dispatcher (this PR changes that route-ledger row's note). The
one red is `route-ledger-live-mount-parity`: `GET
/packages/:id/customizations — LEDGERED BUT NOT MOUNTED`, the row #28595
added on `main`; this diff touches neither that row nor its mount.
- Full dogfood on the merged head `56f66311`: Test Files 1 failed | 364
passed | 3 skipped, Tests 2942 passed; the one failed file is the same
parity red. On the pre-merge head `76b93c18` (base before #28595): 364
passed | 3 skipped, 2938 tests, no failure.
- `check-platform-checklist --changed-from origin/main` exit 0;
`check-commit-card-trailers` exit 0.
- `origin/main` has moved 7 commits since the last merge; none touches a
file in this diff and `git merge-tree` is clean.
## Human Minimum
Each item is one rule from
[CONTRIBUTING.md › Human
Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum),
which links the AGENTS.md text that states it.
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(`packages/spec` is not touched.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran. Neither touched package has a `lint` script;
`eslint` on every touched file exits 0.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: not touched, nothing to regenerate.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English, except the `维护者速读`
summary, which the maintainer asks for in Chinese.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(runtime)!: the permission-set clone door lands "new similar" in a writable target package — by default the source package's `.custom`, never package-less (#28563)
6
+
7
+
Clause-②: yes (narrowing) — `POST /security/permission-sets/:name/clone` takes an optional `package` and its answer names the package the copy landed in (widening); a copy of a set shipped by a read-only package now lands in that package's paired `<X>.custom`, ensured through the pair's one creation path, so a source whose package the ensure door refuses (it declares no namespace, its version is not SemVer, or another package owns the pair's namespace) is refused 409/422 where it was copied package-less before, and a given target that is unknown, read-only or out of reach is refused.
8
+
Metadata-protocol: none
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) No metadata key, value or type changes: the door's HTTP body gains an optional key and its answer gains one, and the copy's own definition is the same whole-definition copy as before. A refused clone writes nothing and its refusal names the remedy, so there is nothing stored for a migration to rewrite and no tombstone. -->
11
+
12
+
**BREAKING** accept-set narrowing at the permission-set clone door (`@objectstack/runtime`).
13
+
14
+
-**Before.** Every copy was saved environment-wide with no package, whatever package shipped the set it was copied from. The body was `{ name, label }`.
15
+
-**Now (ADR-0146 D3/D6).** The body is `{ name, label, package? }` and the answer is `{ permissionSet, package, notice }`.
16
+
-`package` omitted, the set shipped by a read-only package X: the copy is saved in X's paired customization package `<X>.custom`, created through the ensure door (`ensureCustomizationPackage`) on the first copy and reused after. The stored row is bound to the pair; the copy's definition still names no package.
17
+
-`package` omitted, the set already in a writable package (a base you own, or `<X>.custom`): the copy is saved in that package.
18
+
-`package` omitted, the set authored in the environment: unchanged, environment-wide (`package: null`).
19
+
-`package` given: it must be installed and writable (else 422 `WRITABLE_PACKAGE_REQUIRED`), and its dependency closure must reach the source's package and every object the set grants on (else 409 `RESOURCE_CONFLICT`). The rule is the one the flow clone door uses.
20
+
-**Unchanged.** A new name is mandatory and a taken one is 409; `manage_metadata` and the walled catalog refusal answer first; the copy is assignable by its bare name through a position or a direct assignment.
21
+
-**Who sees it.** An administrator copying a packaged set from Setup: the copy now travels with the package's customizations. A copy of a set from a package the ensure door cannot pair is refused with the ensure door's own sentence.
22
+
23
+
**The remedy.** For a refused pair: give the base package a namespace (and a SemVer version), or name a writable target with `package`.
Copy file name to clipboardExpand all lines: content/docs/concepts/metadata-lifecycle.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -180,7 +180,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
180
180
| :--- | :--- |
181
181
|`flow`| Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). 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. |
182
182
|`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. |
183
-
|`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}`) with a new name. The clone door copies the whole definition from the security catalog. |
183
+
|`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). |
184
184
185
185
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.
0 commit comments