Skip to content

feat(runtime)!: the permission-set clone door lands "new similar" in a writable target package, by default the source package's .custom (#28563) - #28636

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-nqql2u
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-nqql2u

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread
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).

维护者速读

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@97489b20 (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 Flow clone door → "new similar": the copy lands in a writable target package, by default the paired <base>.custom, never package-less #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 feat(metadata-protocol,cli,cloud-connection)!: the override write gate — only the base's paired .custom declares an override (ADR-0146 D2) #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 feat(runtime,spec): GET /packages/:id/customizations answers a base's overrides, extensions and switched-off items in one read #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 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/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,
which links the AGENTS.md text that states it.

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec is not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran. Neither touched package has a lint script; eslint on every touched file exits 0.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched, nothing to regenerate.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English, except the 维护者速读 summary, which the maintainer asks for in Chinese.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

… 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>
…pe dispatcher-domain roster

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>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 28 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/runtime/package.json, packages/runtime/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json e098ff46dec6fc0f02fe0776cedfa6189d651e14.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/runtime/package.json, packages/runtime/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 214 client-bound route-ledger rows — the other 154 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 154: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 99 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e098ff46dec6fc0f02fe0776cedfa6189d651e14 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 35e4f231acd746dd0bf6b541424377e134eed6a2 — the merge of head 40a37314195729e9336ee7247401a7e90e903570 into base e098ff46dec6fc0f02fe0776cedfa6189d651e14, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e098ff46dec6fc0f02fe0776cedfa6189d651e14 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 40a37314195729e9336ee7247401a7e90e903570
Local-runs: none
Scope: packages/runtime/src/domains/permission-set-clone.ts, packages/runtime/src/domains/new-similar-target.ts, packages/runtime/src/domains/permission-set-clone.test.ts, packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts, packages/runtime/src/route-ledger.ts, packages/runtime/package.json, packages/runtime/vitest.config.ts, pnpm-lock.yaml, scripts/check-route-envelope.mjs + packages/metadata-protocol/src/protocol-customization-package.ts (the ensure door), packages/metadata-protocol/src/package-writability.ts (isWritablePackage), packages/metadata-protocol/src/protocol.ts (saveMetaItem, permissionSetOwnerBindingRefusal), packages/lint/src/runtime-gate.ts (resolvePackageClosure, narrowObjectsToPackageClosure), packages/runtime/src/domains/packages.ts (the ensure HTTP door, requireDuplicableSource), packages/runtime/src/http-dispatcher.ts (errorFromThrown), packages/spec/src/api/error-code-ledger.zod.ts, docs/adr/0146 D3/D4/D6, and the flow-clone branch claude/project-thread-sdqbrt at its current head (71dca746) for the shared helper and the four shared config hunks. The prose faces (.changeset/**, content/docs/**) are read against the diff below but are the lane seat's ACCEPT, not this record's scope. Face: record-recognisers --paths on the PR's file list decides no path face (no published schema, no governed path) and defers to the changeset's Clause-②: line, which carries the (narrowing) arm, so the narrowing face alone is owed and this record covers it in full; no further face.

① Derived judgments

The accept set, before and after, at POST /security/permission-sets/:name/clone — read off the handler, not the body text.

  1. package omitted, source shipped by a read-only package X (the narrowing). Before: every such copy answered 200 and was saved package-less. Now resolveDefaultCloneLanding calls protocol.ensureCustomizationPackage({ basePackageId: X }) and the save carries the pair as its own packageId. The inputs that move from accept to refuse are exactly the ensure door's own refusals, read in ensurePairOnce on main: X declares no namespace, or its namespace has no valid pair namespace (too long, invalid) → 409; the pair is absent and X's version is not SemVer → 409; another package already owns X's pair namespace → 422 NAMESPACE_CONFLICT; an installed X.custom that lacks the namespace or the dependency on X → 409; X's id yields no valid pair id → 400; no package registry → 503; the pair's own install refused by the ADR-0141 D2 dependency check (a base at a prerelease version) → 422 DEPENDENCY_MISSING (the class declares status and code itself). One more, in a non-standard composition only: a protocol with saveMetaItem but no ensureCustomizationPackage → 501 (before: a package-less save). Correct: a refused pair means the copy has nowhere to land that D3/D6 admit, and a package-less fallback would be the second destination the unblock forbids. The PR body and changeset name the three common ones (no namespace, non-SemVer version, namespace owned elsewhere) and say "the ensure door's own sentence"; that is a true and sufficient summary of this list.
  2. No new error code. The wire codes are: 422 WRITABLE_PACKAGE_REQUIRED and 409 RESOURCE_CONFLICT from the shared rule, both in packages/spec/src/api/error-code-ledger.zod.ts; the ensure door's 422 NAMESPACE_CONFLICT, ledgered; its code-less 409s take the dispatcher's status-derived code through deps.errorFromThrown(err, 500) — the same path the HTTP ensure door (packages.ts) answers them through, so the two doors answer one refusal with one envelope. DUPLICATE_SOURCE_NOT_A_BASE is deliberately not reused, and the helper's header says why (that code judges a SOURCE, these refusals judge a TARGET); what the card's "family" asks for — the [tag] sentence, packageId and docs in the envelope's details, refuse-before-mutate — is the shape newSimilarTargetRefusalAnswer reproduces from requireDuplicableSource. Judged right.
  3. Ensure door called in-process, not re-implemented. The clone door reads protocol.ensureCustomizationPackage off the resolved protocol service, which on main is the class method delegating to ensureCustomizationPackage.call(host, request) in protocol-customization-package.ts. No pair id, namespace or range is spelled in the runtime; isWritablePackage is the one ADR-0070 D2 predicate imported from @objectstack/metadata-protocol. The unit suite's ensure double is a model of the contract (created on first call, reused after), and the dogfood drives the real one (404 before the first copy, 200 after; one pair in GET /packages after the second). The unblock's "no second creation path" holds.
  4. The row binding goes through the save's package context. saveMetaItem({ type, name, item, parentVersion: null, packageId }) — the landing is spread onto the request only when non-null; the item still loses packageId through the unchanged PERMISSION_SET_CLONE_DROPPED_KEYS. permissionSetOwnerBindingRefusal compares a BODY packageId with the row's resolved binding and the body has none, so it never fires; the dogfood pins the stored row's package_id is the pair while metadata has no packageId. Correct, and the order's hypothesis is met exactly.
  5. New-name rule and its 409 kept; a set in .custom under the base set's name still refused. Step 5 is unchanged: a name the catalog holds — the source's own included — answers 409 before any landing is resolved or any save attempted; the dogfood's taken-name case still passes. Since the catalog is environment-level and names are unique across packages (D6: one name per deployment), a clone named after its base is refused here regardless of the landing; the clone door does not depend on N-09's save-side refusal (not on main yet, PR feat(metadata-protocol,cli,cloud-connection)!: the override write gate — only the base's paired .custom declares an override (ADR-0146 D2) #28554 being reworked).
  6. Assignable by bare name — the card's unverified item, measured. The dogfood grants my_member_default (asserted bound to the pair) through a position saved by name and resolves it in resolveUserAuthzGrants; a new case grants my_field_ops_delegate (pair-bound) through a direct sys_user_permission_set row, reading it absent first (anti-vacuity). Also measured: the catalog serves the pair-bound copy with packageId = pair — the card's other unverified line. Wanted 3 is met by measurement, not assertion.
  7. Walled tenant administrator — both refusals stand. The clone door asks metaWriteCapabilityVerdict then walledCatalogWriteVerdict before any read and before the landing step, so under isolated / group a tenant administrator gets the walled sentence and no pair is ensured (the unit suite now pins ensures empty under both postures). The ensure HTTP door keeps its own requireManageMetadata plus walled package-write 403 at its own route; the in-process body judges packages only, by its own header, and its authority is the same pair of predicates the clone door asks. No privilege is widened by calling it in-process.
  8. Environment-source control unchanged. source.packageId undefined → { packageId: null }, no ensure, the save's keys exactly as before (item, name, parentVersion, type), answer package: null; pinned in unit and dogfood.
  9. A source already in a writable package (an owned base or an existing .custom) now lands in that package rather than package-less — a landing change, not a refusal. The ensure door would refuse a writable base 409, so this is the only correct reading. The save into that package is judged by the save door's closure-narrowed gate, the same gate that admitted the source there; I read no practical input this moves to refuse.
  10. package given. Before: 400 (unknown key). Now: a writable installed target whose closure reaches the source's package and every granted object lands there; unknown or read-only (booted code package, or scope in the read-only set) → 422 WRITABLE_PACKAGE_REQUIRED naming the package; closure missing the source's package, or a granted object owned by a package outside the closure → 409 RESOURCE_CONFLICT; empty or non-string → 400 VALIDATION_FAILED on field package, never read as omitted. The objects limb reuses narrowObjectsToPackageClosure with the same four limbs as the save gate (system objects and unpackaged ones admitted; an object no registry holds skipped and left to the save). permissionSetObjectNames reads the three places PermissionSetSchema names an object (objects keys, the object half of fields keys, row-level rules' object). All judged right; refuse-before-mutate holds (the dogfood checks no catalog entry and no sys_metadata row after each refusal).
  11. Answer shape. { permissionSet, package, notice }, package null environment-wide; the notice prefixes a landing sentence to the unchanged PERMISSION_SET_CLONE_NOTICE. No ancestry key (ADR-0126 §9) — the copy still names nothing about its source.
  12. The four non-domain files. packages/runtime/package.json + pnpm-lock.yaml: a direct @objectstack/lint workspace dependency for the two closure walks; @objectstack/lint already sits in runtime's closure through @objectstack/metadata-protocol, and lint depends on neither runtime nor metadata-protocol (deps: formula, sdui-parser, spec), so no cycle and no new install weight. packages/runtime/vitest.config.ts: an alias of @objectstack/lint/runtime to ../lint/src/runtime.ts, which check:test-source-alias requires once runtime's tests reach that subpath; it makes the tests judge source instead of dist/, a strengthening. scripts/check-route-envelope.mjs: one DISPATCHER_DOMAINS row declaring new-similar-target.ts at handBuilt: 0, the strictest count (its one answer goes through deps.error); the gate reds on an undeclared domain file, so this is the roster entry a new file owes, not a weakened gate. scripts/** is not a governed surface. All four hunks are byte-identical to the flow-clone branch's.
  13. Route-ledger note. The row stays server-only with no requestSchema, so Wanted 6's conditional (a Zod request schema in packages/spec) is not triggered and none is invented; the note's new sentences (body, landing rules, both refusal codes, the answer's package, "the package is the save's own packageId, never a key of the item") match the handler line for line.
  14. Docs (content/docs/permissions/permission-sets.mdx, content/docs/concepts/metadata-lifecycle.mdx): every sentence I checked is true of the code — the two refusal codes, the ensure door as the only creation path, package: null environment-wide, assignment by bare name, the dropped-keys list unchanged. Lane seat's ACCEPT confirms the prose; nothing false found here.

② Semver level

Clause-②: yes (narrowing) is the arm scripts/pm/clause2-line.mjs defines for exactly this diff — "a diff that widens one surface and narrows another. Both facts are true and both are read": the body gains an optional key and the answer a key (widening), and a request that answered 200 now refuses (narrowing). Metadata-protocol: none is honest: no key an author writes, no accepted value and no metadata type changes; an HTTP body key is not metadata, and the copy's stored definition is the same whole-definition copy as before. Per AGENTS.md Post-Task Checklist step 3 the level follows the kind alone — none takes patch, "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition" — so '@objectstack/runtime': patch is right, the **BREAKING** banner is present, and the changeset carries exactly one ADR-0087 marker, not-required (no-migration-prescription), a category check-adr-0087-registration.mjs lists; its reasoning (nothing stored to rewrite, a refused clone writes nothing, the remedy is in the refusal) holds. The PR title's ! matches. Only @objectstack/runtime publishes a changed file (packages/qa/dogfood is a test package; scripts/**, docs and the changeset ship in no package), so one changeset is the right count.

③ Boundary flags

④ Design judgment

The narrowing, judged against the published behaviour on main: the door advertised a copy "authored in this system: no package" (its docs and ledger note). ADR-0146 D3 ("It receives ... environment-level permission sets for the base (new sets, never an override of the base's — D6)") and D6 ("A customer's own set is a new set in .custom") decide the landing; D3 makes the ensure door the only creation path. So the narrowing takes no declared capability away at the consumer — a copy is still made for every source the pair can be ensured for, and the console reads a response that only gained a key — and it makes the ADR's own refusal real where the alternative was a silent second destination. That is the right side of Prime Directive #12: contract-first, no lenient fallback.

Per Scope file: permission-set-clone.ts holds against the scenario it is accepted on (card Tests, all six measured in dogfood and unit); the step order (authority → walled → shape → body → source → taken name → landing → save) keeps every refusal before any write and the pair un-ensured for a refused caller. new-similar-target.ts is pure over the registry, reuses the two lint walks the save gate and Studio's pickers use (one closure for picker and gate, the lint header's own invariant), and its objects limb is the right place for a door whose item declares its objects; a flow passes none, as the header states. route-ledger.ts, check-route-envelope.mjs, vitest.config.ts, package.json/lockfile: each is the one edit its gate or import requires, none weakens a gate. The tests: the ablation record is credible — the source-package limb's first ablation was green because its only fixture tripped the objects limb too, and the dev added a fixture that isolates the limb before re-running, which is the honest shape.

Two observations, neither a must-fix: (a) the HTTP ensure door answers a prerelease base's refused pair install through dependencyMissingAnswer to keep the per-entry details; the clone door answers the same throw through errorFromThrown, which keeps the status, the code and the issues array but not that closed details list — the same status and code reach the caller, so the contract holds; a follow-up could share the branch. (b) The default landings (the pair, or the source's own writable package) are not asked the target rule; the save gate's closure judges them, which the dev states in the acceptance notes. Agreed: the pair depends on X by construction and a set already in a package resolved there.

Implemented-by: claude/project-thread-nqql2u
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-01T10:14Z · head 40a37314 · #28563

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main with no assignees, Fixes #28563, and a patch changeset for @objectstack/runtime. The title carries !.
    • check-governed-merges --pr 28636 reads NOT governed, 795 changed lines. Landing tier S.
    • The claim (6554953298) names this PR's branch, claude/project-thread-nqql2u.
  2. Against the order:
    • POST /security/permission-sets/:name/clone takes an optional target package, and its answer names the package the copy landed in.
    • With no target, a set shipped by package X lands in <X>.custom, which N-13's ensureCustomizationPackage creates in-process. No second creation path is introduced. The binding goes through saveMetaItem's own package context, not a body key.
    • An environment-authored source is unchanged.
    • A given target must be installed, writable and in reach. Otherwise the door answers 422 WRITABLE_PACKAGE_REQUIRED or 409 RESOURCE_CONFLICT, both existing ledgered codes.
    • The new-name rule and its 409 stand, and so does the walled refusal.
    • The copy is assignable by bare name, through a position and through a direct assignment, as measured.
    • Clause-②: yes (narrowing). A source whose pair the ensure door refuses now gets that refusal, where it was copied package-less before. Metadata-protocol: none: no spec schema moves.
  3. Contract review: owed for the narrowing. PASS at CONTRACT_REVIEW_TIER on this head, comment 6556512923.
  4. Gates: CI on head 40a37314 shows 59 success, 7 skipped and 0 red. The state is clean.
  5. Overlap:
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 10:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 1, 2026 10:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 6e11a92 Nov 1, 2026
67 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-nqql2u branch November 1, 2026 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant