Repository navigation
feat(runtime): POST /security/permission-sets/:name/clone, the permission-set clone door (#15204 stage CD) - #22881
Conversation
…ssion-set clone door (ADR-0126 §7.1) Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
… on the census page Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
… door's row Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
…lope, and recount the route ledger Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
…one-door # Conflicts: # content/docs/permissions/system-context.mdx # packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
…om main, one from the clone door) Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 26 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 73fd4081b156ee6724f8fa0ac0678601124dc2e0 && git checkout 73fd4081b156ee6724f8fa0ac0678601124dc2e0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 61449d236a51cac089b5d331cf301f852f8896dc && git checkout -B drift-repro 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 && git merge --no-ff 61449d236a51cac089b5d331cf301f852f8896dc
node scripts/docs-audit/affected-docs.mjs --json 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388
|
Contract reviewServed-tier: Inputs read: card #15204 body and all 125 comments (the claim amendment 6111348289, the stage-8 seat answer 6110777865 and the dev report 6112530240 among them); objectui#7611 comment 6097763840 (Q1 → A); PR #22881 body, file list and net diff against Gate verdicts on this head (19:13Z): 29 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
… say what keeps provenance off the clone; the 400 states the name rule the schema enforces Claude-Session: https://claude.ai/code/session_01999rKpwstpg8Jysr6vN6JC Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta round. The previous head Inputs read: card #15204 body and all 127 comments (the claim amendment 6111348289 opening stage CD, the dev report 6112530240, the seat note 6112750171, the dev's addendum 6113091553, the stage-8 seat answer 6110777865, the triage pointer 6070695107 and the release-cut conditions 6102862135 among them); objectui#7611 comment 6097763840 (Q1 → A), read directly; PR #22881 body (as patched in this round), its 14-file list and the net diff against Gate verdicts on this head: 42 check-runs, 38 ① Derived judgmentsThe delta (brief item a) — right, and it is the whole change.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Part of #15204
Clause-②: yes (widening)
Stage CD of the #15194 epic: the permission-set clone door. The seat ruled it on objectui#7611 (Q1 → A, comment 6097763840) and the PM opened the stage in claim amendment 6111348289. Retiring
clone_permission_setandActionParamSchema.carryOveris stage 8's work and stays out of this PR. objectui PR-4 switches the console's Clone dispatch to this route after it lands.What lands
POST /api/v1/security/permission-sets/:name/clone, body{ name, label }.securityCatalogReaderOf, registry first, then the metadata service), the same readresolveUserAuthzGrantsuses. It reads nosys_permission_setrow, so it works on a database that has none. The unit double throws on every data read, and the dogfood pin deletes the source's rows before it clones.protocol.saveMetaItem({ type: 'permission', name, item, parentVersion: null })saves an environment-authored item with no organization and no package./meta's schema and its hooks judge the copy. The save does not keep the source's provenance off the copy: its authoring rule (tenantAuthoredWriteRefusal) judgesflowwrites only and answers null forpermission, and its provenance strip is silent. The guarantee isPERMISSION_SET_CLONE_DROPPED_KEYSand the unit pin that asserts every dropped key absent from the saved item.parentVersion: nullis the create-only pin (If-None-Match: *), so a row written under the target name after the catalog read is refused rather than overwritten.PERMISSION_SET_CLONE_DROPPED_KEYS(packages/runtime/src/domains/permission-set-clone.ts) holds:MetadataProtectionFields,METADATA_READ_DECORATIONS);packageId,managedByandprotection;adminScopeandisDefault.Everything else crosses as a deep copy:
objects,fields,systemPermissions,tabPermissions,rowLevelSecurityanddescription. No facet is enumerated, which follows ADR-0126 §7.1 ("never param-list assembly"). The response is{ permissionSet, notice }. It carries no ancestry (ADR-0126 §9), and the notice says what was not copied.metaWriteCapabilityVerdict(manage_metadata, withisSystembypassing) before any path, body or catalog read. This adds no gate and is not the ADR-0126 §5 activation gate ([Decision] 切换后谁能在 Setup 里新建/编辑岗位和权限集:保持今天的权限(持 manage_metadata 者),还是放开给组织管理员(安全边界) #22621 → A; four axes: a new gate is "no" by default).PERMISSION_DENIED: the caller lacksmanage_metadata;VALIDATION_FAILED: a name failingSnakeCaseIdentifierSchema(^[a-z][a-z0-9_]*$, at least 2 characters — the rulePermissionSetSchema.nameuses, which the message states), or a body that is not exactly{ name, label };RESOURCE_CONFLICT: a name the catalog already holds, the source's own included;SERVICE_UNAVAILABLE: no catalog is bound, or the catalog read fails;Where it lives, and why
The path follows the flow precedent's family.
POST /automation/:name/cloneis an arm of the dispatcher's/automationdomain, mounted bydispatcher-plugin.tsand ledgeredserver-onlyinpackages/runtime/src/route-ledger.ts. The nearest sibling isPOST /security/_activation/:type/:name, the one/securityroute the dispatcher already mounted, which reads the same catalog. So the clone arm sits in the dispatcher's/securitydomain (domains/security.ts→domains/permission-set-clone.ts) and is mounted beside the activation route. The route ledger gains aserver-onlyrow, with the activation row's note adjusted to say there are now two such routes. Following the flow row, the route gets no client SDK method: its caller is the Setup console, and adding a method later reclassifies the row tosdk. The pluralpermission-setssegment follows the REST naming rule. Nothing inpackages/restmounts anything under/security/permission-sets(the old discard-overlay route there is retired), so nothing collides.The landing zone is
packages/runtimerather thanpackages/rest. The measurement placed the producer family there: the REST server owns the rest of/security, but the flow clone and the catalog activation doors both live in the runtime dispatcher.Docs
content/docs/permissions/permission-sets.mdx: a new section, "Cloning a permission set", and a pointer from the locked-base bullet.content/docs/concepts/metadata-lifecycle.mdx: thepermissionrow of the sanctioned-path table names the door.content/docs/permissions/system-context.mdx: row 49 anchors the clone door'smanage_metadataverdict. The census gate (check:system-context-census) found the newisSystemread site, andgen:system-context-censusrecomputed the counts.Tests
packages/runtime/src/domains/permission-set-clone.test.ts, 16 cases, including:packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts, 4 cases on a booted showcase:manage_metadataholder clones the packagedshowcase_field_ops_delegate(it hasadminScope) after itssys_permission_setrows are deleted, and the copy resolves through the catalog with no package, noadminScopeand the source'sobjects;showcase_member_default(isDefault: true) clones withoutisDefault, and a holder of a position naming the copy resolves the copy's grants;manage_metadatagets 403PERMISSION_DENIED, and nothing resolves under the attempted name.scripts/ablation-replace.mjs): removing'adminScope'fromPERMISSION_SET_CLONE_DROPPED_KEYSturned two unit cases red (2 failed / 14 passed). The restore left the blob equal to HEAD andgit diff HEADempty.Local verification
Measured on
6d4ffaea5(the merge oforigin/mainat6d5191e69). The only commit after it,2bc6c13c6, recountssystem-context.mdx.pnpm --filter @objectstack/runtime typecheck: green, includingcheck:test-typecheck.pnpm --filter @objectstack/runtime test: 352 files passed; 5086 tests passed, 19 skipped.pnpm --filter @objectstack/dogfood test, the full suite: 250 files passed, 1 skipped; 2017 tests passed, 9 skipped.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 115 commands derived. 113 were re-run on the merged head and all exit 0 after the census recount. The other two,check:pm-dispatch-gates(fast tier, 1828 cases) andcheck:type-check-debt, ran green on9bfb9ebf8before the merge.--ranreconciles 115 run and 0 not measured.eslint --no-inline-configover the touched TypeScript and script files: clean.What the gates found during the run, and what fixed it:
check:system-context-census: the newisSystemread site got a row 49 anchor. The counts were recomputed again after the merge, becausemainalso added a site.check:dispatcher-error-vocabulary: the body reader'srequiredandinvalid_typefield codes are classifiedforeign-vocabulary, as the flow clone door's are.check:route-envelope: declarespermission-set-clone.tswith{ handBuilt: 0 }.check:route-ledger-census:--fixrecounted the runtime ledger sentence.check:engine-double-contract: the unit double dropped an unusedfindOne.authz-conformance/authz-probe-blind-spot: the runtime ledger count moves 87 → 88 (main's inbound-hook row took 86 → 87 in the same window).Acceptance notes
packaged-base-regime.ts(PACKAGED_BASE_REGIME.permission.routes.clone) andpackaged-permission-set-lock.tssay "the Clone action … orPOST /api/v1/data/sys_permission_set". The stage-8 seat answer (refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 comment 6110777865) assigns that prose to stage 8, so it is untouched here. The metadata-lifecycle docs row names the new door and says the refusal does not name it yet.clone_permission_setandActionParamSchema.carryOverare unchanged; stage 8 retires them.Generated by Claude Code