Repository navigation
feat(runtime)!: the permission-set clone door lands "new similar" in a writable target package, by default the source package's .custom (#28563) - #28636
Conversation
… writable target package A copy of a packaged set with no target lands in the source package's paired <X>.custom, ensured through the ensure door; a given target must be installed, writable and reach the set's objects (the shared new-similar target check); an environment-authored source copies environment-wide. Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude <noreply@anthropic.com>
…lper, add the objects limb, ledger note, tests, changeset Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude <noreply@anthropic.com>
…pe dispatcher-domain roster Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude <noreply@anthropic.com>
…t through whole, and runtime tests alias @objectstack/lint/runtime to source Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 35e4f231acd746dd0bf6b541424377e134eed6a2 && git checkout 35e4f231acd746dd0bf6b541424377e134eed6a2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e098ff46dec6fc0f02fe0776cedfa6189d651e14 40a37314195729e9336ee7247401a7e90e903570 && git checkout -B drift-repro e098ff46dec6fc0f02fe0776cedfa6189d651e14 && git merge --no-ff 40a37314195729e9336ee7247401a7e90e903570
node scripts/docs-audit/affected-docs.mjs --json e098ff46dec6fc0f02fe0776cedfa6189d651e14
|
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo Contract reviewServed-tier: ① Derived judgmentsThe accept set, before and after, at
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing, judged against the published behaviour on Per Two observations, neither a must-fix: (a) the HTTP ensure door answers a prerelease base's refused pair install through Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo 席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #28563
Clause-②: yes (narrowing) —
POST /security/permission-sets/:name/clonetakes an optionalpackageand 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: thepackagekey, where a copy lands, the two refusals, the answer'spackage) and thepermissionrow ofcontent/docs/concepts/metadata-lifecycle.mdx; no page underskills/**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字段,说明落在哪。member_default等)实测都能正常配对落地。回滚即 revert。packages/runtime/src/domains/permission-set-clone.ts(第 6 步落点与保存时的packageId);packages/runtime/src/domains/new-similar-target.ts(与 Flow clone door → "new similar": the copy lands in a writable target package, by default the paired <base>.custom, never package-less #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).{ name, label, package? }, still closed. An empty or non-stringpackageis 400VALIDATION_FAILEDon fieldpackage, never read as omitted.packagegiven: the shared rulenewSimilarTargetRefusal(below) judges it, asked of the source's package and of every object the set grants on (permissionSetObjectNames: theobjectskeys, the object half of eachfieldskey, each row-level rule'sobject). A refusal answers throughnewSimilarTargetRefusalAnswer, sopackageIdanddocsreach the envelope's details.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.saveMetaItem's ownpackageId. The item still dropspackageId(PERMISSION_SET_CLONE_DROPPED_KEYSis unchanged), sopermissionSetOwnerBindingRefusalnever sees a body key disagreeing with the row.{ permissionSet, package, notice };packageisnullenvironment-wide.permissionSetCloneNotice(landing)puts the landing sentence ahead of the unchangedPERMISSION_SET_CLONE_NOTICE.manage_metadata, then the walled catalog refusal, both before any read and before the pair is ensured, so a tenant administrator underisolated/groupgets 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 atclaude/project-thread-sdqbrt@97489b20(unknown or read-only → 422WRITABLE_PACKAGE_REQUIRED; a closure that does not reach the source's package → 409RESOURCE_CONFLICT, throughresolvePackageClosure), plus one limb this door needs: an optionalobjectslist, each judged by the save gate's own closure predicatenarrowObjectsToPackageClosure(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/lintjoins runtime's dependencies for the two walks (the same line #28149 adds). The flow door passes noobjects(it has no honest object list).Contract. The route ledger lists the door as
server-onlywith norequestSchema(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'spackage. No error code is minted (both codes are registered). Docs: the two pages named on theSkills-docs:line.Gate roster.
scripts/check-route-envelope.mjsDISPATCHER_DOMAINSdeclaresnew-similar-target.ts{ handBuilt: 0 }(the gate reds on an undeclared file indomains/); the #28149 thread has the same two lines.Test alias.
packages/runtime/vitest.config.tsaliases@objectstack/lint/runtimeto../lint/src/runtime.ts, byte-identical to #28149's line, so the rule's tests judge the source (check:test-source-aliasreds without it once runtime imports the subpath).System-context census. The objects limb hands the registered object to
narrowObjectsToPackageClosurewhole ({ ...object, name, _packageId }) rather than readingisSystemoff it, whichcheck:system-context-censusreads 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
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 nopackagekey; the target case (its fixture ids were then fixed from underscores to hyphens).permission-set-clone-door.dogfood.test.ts, real showcase boot): 11/11. Measured there:com.example.showcase.custom(404 before, 200 after), the storedsys_metadatarow'spackage_idis the pair, itsmetadatahas nopackageId, and the catalog serves the copy withpackageId= the pair;GET /packages;sys_user_permission_setrow (resolveUserAuthzGrantslists it; the direct leg reads it absent first);member_default, shipped by the security plugin's manifest, namespacesys) lands in its own pair;WRITABLE_PACKAGE_REQUIRED, a writable target declaring nothing is 409RESOURCE_CONFLICT, each with no catalog entry and nosys_metadatarow;package: nulland a row whosepackage_idis null.permission-set-clone.test.ts, 35/35): the save'spackageIdand 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 (acrmset granting onlysys_user) and by an object alone (an environment set grantingaccount); a platform-only set reachable from any target; emptypackage400; an ensure refusal keeps its status; no ensure verb 501; walled tenant refused with no ensure call.node scripts/ablation-replace.mjs; the runtime suite imports the door and the rule fromsrc/, so no build sits between mutation and run; each restore read blob == HEAD and an emptygit diff HEAD):const pair = await protocol.ensureCustomizationPackage(...)→ a null pair): 3 red (default landing, pair reuse, ensure refusal);if (request.objects !== undefined && request.objects.length > 0) {→if (false as boolean) {): 1 red (the environment set given an island target);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 thecrm-ships-sys_user-only case and re-ran: 1 red.pnpm --filter @objectstack/runtime typecheckexit 0;pnpm --filter @objectstack/runtime teston the final head: Test Files 502 passed (502), Tests 8170 passed | 19 skipped (8189).Acceptance notes
yes (widening)with a narrowing arm ifCLAUSE2_ARMSreads 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 isyes (narrowing)with the**BREAKING**banner and the ADR-0087not-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 assys_custom); the refusal path is the ensure door's own, read from its code.objectslimb and the route-envelope roster line. Whichever lands first owns the file; the other mergesorigin/mainand takes it.<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.packages/metadata-protocol/src/protocol.tsandprotocol-packaged-base-refusal.ts; this diff touches neither.Verification
All on head
40a37314unless 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-debtincluded (0 raw tsc error(s) total, none above its recorded number, underos-verify-lock).permission-set-clone.test.ts35/35.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 toPOST /api/v1/security/permission-sets/:name/clonethrough the real dispatcher (this PR changes that route-ledger row's note). The one red isroute-ledger-live-mount-parity:GET /packages/:id/customizations — LEDGERED BUT NOT MOUNTED, the row feat(runtime,spec): GET /packages/:id/customizations answers a base's overrides, extensions and switched-off items in one read #28595 added onmain; this diff touches neither that row nor its mount.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 head76b93c18(base before feat(runtime,spec): GET /packages/:id/customizations answers a base's overrides, extensions and switched-off items in one read #28595): 364 passed | 3 skipped, 2938 tests, no failure.check-platform-checklist --changed-from origin/mainexit 0;check-commit-card-trailersexit 0.origin/mainhas moved 7 commits since the last merge; none touches a file in this diff andgit merge-treeis clean.Human Minimum
Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.
packages/specholds no runtime logic. (packages/specis not touched.)lintscript;eslinton every touched file exits 0.main, one pull request.packages/spectouched: not touched, nothing to regenerate.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.维护者速读summary, which the maintainer asks for in Chinese.Generated by Claude Code