Skip to content

feat(runtime): POST /security/permission-sets/:name/clone, the permission-set clone door (#15204 stage CD) - #22881

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-15204-clone-door
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-15204-clone-door

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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_set and ActionParamSchema.carryOver is 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 }.

  • Reads the source from the security catalog, never from a row. It goes through the catalog reader the security plugin binds to the engine (securityCatalogReaderOf, registry first, then the metadata service), the same read resolveUserAuthzGrants uses. It reads no sys_permission_set row, 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.
  • Writes the copy through the metadata door. 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) judges flow writes only and answers null for permission, and its provenance strip is silent. The guarantee is PERMISSION_SET_CLONE_DROPPED_KEYS and the unit pin that asserts every dropped key absent from the saved item. parentVersion: null is the create-only pin (If-None-Match: *), so a row written under the target name after the catalog read is refused rather than overwritten.
  • Copies the whole definition and drops one server-declared list. PERMISSION_SET_CLONE_DROPPED_KEYS (packages/runtime/src/domains/permission-set-clone.ts) holds:
    • the provenance envelope and read decorations, derived from the spec (MetadataProtectionFields, METADATA_READ_DECORATIONS);
    • packageId, managedBy and protection;
    • the two privilege decisions, adminScope and isDefault.
      Everything else crosses as a deep copy: objects, fields, systemPermissions, tabPermissions, rowLevelSecurity and description. 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.
  • Authority is the metadata door's own. The route asks the shared metaWriteCapabilityVerdict (manage_metadata, with isSystem bypassing) 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).
  • Refusals use the ADR-0112 envelope:
    • 403 PERMISSION_DENIED: the caller lacks manage_metadata;
    • 401: anonymous, at the domain floor;
    • 400 VALIDATION_FAILED: a name failing SnakeCaseIdentifierSchema (^[a-z][a-z0-9_]*$, at least 2 characters — the rule PermissionSetSchema.name uses, which the message states), or a body that is not exactly { name, label };
    • 404: an unknown source;
    • 409 RESOURCE_CONFLICT: a name the catalog already holds, the source's own included;
    • 503 SERVICE_UNAVAILABLE: no catalog is bound, or the catalog read fails;
    • 501: no metadata protocol is composed;
    • a failed save keeps the store's own status and code.

Where it lives, and why

The path follows the flow precedent's family. POST /automation/:name/clone is an arm of the dispatcher's /automation domain, mounted by dispatcher-plugin.ts and ledgered server-only in packages/runtime/src/route-ledger.ts. The nearest sibling is POST /security/_activation/:type/:name, the one /security route the dispatcher already mounted, which reads the same catalog. So the clone arm sits in the dispatcher's /security domain (domains/security.ts → domains/permission-set-clone.ts) and is mounted beside the activation route. The route ledger gains a server-only row, 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 to sdk. The plural permission-sets segment follows the REST naming rule. Nothing in packages/rest mounts anything under /security/permission-sets (the old discard-overlay route there is retired), so nothing collides.

The landing zone is packages/runtime rather than packages/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: the permission row of the sanctioned-path table names the door.
  • content/docs/permissions/system-context.mdx: row 49 anchors the clone door's manage_metadata verdict. The census gate (check:system-context-census) found the new isSystem read site, and gen:system-context-census recomputed the counts.

Tests

  • packages/runtime/src/domains/permission-set-clone.test.ts, 16 cases, including:
    • the whole-definition copy, asserted by deep equality against the source minus the exported dropped list;
    • every dropped key absent from the copy;
    • the create-only save, with no organization or package;
    • no shared references between the copy and the catalog definition;
    • the authority ordering: a non-holder sending an unknown source and a bad body gets 403;
    • every refusal's status and code, with nothing saved.
  • packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts, 4 cases on a booted showcase:
    • a manage_metadata holder clones the packaged showcase_field_ops_delegate (it has adminScope) after its sys_permission_set rows are deleted, and the copy resolves through the catalog with no package, no adminScope and the source's objects;
    • showcase_member_default (isDefault: true) clones without isDefault, and a holder of a position naming the copy resolves the copy's grants;
    • 409, 404 and 400 over HTTP;
    • a signed-up member without manage_metadata gets 403 PERMISSION_DENIED, and nothing resolves under the attempted name.
  • Ablation on a committed tree (scripts/ablation-replace.mjs): removing 'adminScope' from PERMISSION_SET_CLONE_DROPPED_KEYS turned two unit cases red (2 failed / 14 passed). The restore left the blob equal to HEAD and git diff HEAD empty.

Local verification

Measured on 6d4ffaea5 (the merge of origin/main at 6d5191e69). The only commit after it, 2bc6c13c6, recounts system-context.mdx.

  • pnpm --filter @objectstack/runtime typecheck: green, including check: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) and check:type-check-debt, ran green on 9bfb9ebf8 before the merge. --ran reconciles 115 run and 0 not measured.
  • eslint --no-inline-config over the touched TypeScript and script files: clean.

What the gates found during the run, and what fixed it:

  • check:system-context-census: the new isSystem read site got a row 49 anchor. The counts were recomputed again after the merge, because main also added a site.
  • check:dispatcher-error-vocabulary: the body reader's required and invalid_type field codes are classified foreign-vocabulary, as the flow clone door's are.
  • check:route-envelope: declares permission-set-clone.ts with { handBuilt: 0 }.
  • check:route-ledger-census: --fix recounted the runtime ledger sentence.
  • check:engine-double-contract: the unit double dropped an unused findOne.
  • Dogfood 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


Generated by Claude Code

…ssion-set clone door (ADR-0126 §7.1)

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>
…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>
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 27 documentable anchor(s).

36 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 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388.

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

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 26 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 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 73fd4081b156ee6724f8fa0ac0678601124dc2e0 — the merge of head 61449d236a51cac089b5d331cf301f852f8896dc into base 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388, 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 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

⚠️ 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 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2bc6c13c6132c07d2952e554727282b0783c52eb
Local-runs: none

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 main at this head; ADR-0126 §7.1/§9 and ADR-0131 D3/D4/D6 on main; the flow precedent POST /automation/:name/clone (domains/automation.ts) and the sibling door domains/catalog-activation.ts on main; the check-runs on this head, re-read at 2026-10-11T19:13Z.

Gate verdicts on this head (19:13Z): 29 success, including all three Dogfood Regression Gate shards and their aggregate, Test Core 1/2/4/5/6, Temporal Conformance, Check Changeset, all five type-check lanes, Spec property liveness and the three claim guards. 1 failure: Build Docs (the step "Build Docs (the command production runs)", apps/docs pnpm run build, exit 1; its job log sits on a host this seat's proxy refuses, so the cause is read from the diff below). Still pending at render: Lint & Repo Gates and Test Core (3/6). 2 roster skips (Console Pin Gate, Packed-tarball smoke).

① Derived judgments

  1. The route and its mount — right. POST /security/permission-sets/:name/clone is an arm of the dispatcher's /security domain, placed after the domain's anonymous floor and the activation arm and before the suggested-bindings service probe (security.ts), and mounted on the wire by registerSecurityActivationRoutes for every base the activation door gets. On main nothing in packages/rest mounts under /security/permission-sets (the discard-overlay row is a RETIRED comment at rest-route-ledger.ts:421), so nothing collides. The three green Dogfood shards are the on-the-wire proof; the HTTP pin is in shard scope.
  2. The body accept set — right, one message wrong. Closed { name, label }, both non-empty strings, name judged by SnakeCaseIdentifierSchema — the same rule PermissionSetSchema.name declares (spec/src/security/permission.zod.ts:817), so the door's pre-check refuses nothing the save would accept and accepts nothing the save would refuse. Unknown keys answer unknown_field, a non-object body invalid_type, a missing key required. Wrong, text only: the 400 message says a name may start "with a letter or underscore"; the schema is ^[a-z][a-z0-9_]*$ with a minimum of 2, so a leading underscore is refused by the very rule the message explains.
  3. The match predicate — right. parts[0] === 'permission-sets' && parts[2] === 'clone' with no depth bound, and the handler refuses any depth other than 3 with 400 (pinned). That is the flow precedent's "a gate narrower than its route is a bypass" shape.
  4. Authority — right as ruled; one consequence named in ③. metaWriteCapabilityVerdict({ operation: 'save' }) is asked before the path, the body and every read: isSystem admits, manage_metadata admits, anything else (an absent or non-array systemPermissions included) is refused 403 (metadata-core/src/meta-write-capability.ts:85–91). It is the verdict domains/meta.ts:879 asks for a save, so the door's authority is the /meta door's, which is what [Decision] 切换后谁能在 Setup 里新建/编辑岗位和权限集:保持今天的权限(持 manage_metadata 者),还是放开给组织管理员(安全边界) #22621 → A fixes. It is not the ADR-0126 §5 activation gate, and the flow precedent says why in its own docblock (isFlowActivationWrite: "clone is deliberately NOT here … a clone creates an ordinary new artifact under a new name and takes nothing away from any tenant"). The anonymous 401 is the domain floor's, ahead of the arm.
  5. The source read — right. securityCatalogReaderOf(ql).resolve('permission', name) is the resolver's own read (registry first, then the metadata service); no sys_permission_set row is read; the unit double throws on every find, and the dogfood pin deletes the packaged source's rows before it clones. A set whose owning package is disabled resolves to nothing by the reader's rule (security-catalog.ts:89–90), so the door answers 404 for it — correct by construction.
  6. New name, mandatory — right. A target the catalog holds, the source's own name included, is 409 RESOURCE_CONFLICT. Edge, named not faulted: a target name held only by a disabled package resolves to nothing, so the 409 does not fire; the save then meets refusePackagedBaseOverride, which saveMetaItem asks on every topology ahead of the store write (protocol.ts:20081), and the door relays the store's 403 NOT_OVERRIDABLE. No same-name clone can land.
  7. The copy — right, and it is the ruling's sentence. structuredClone(definition) minus PERMISSION_SET_CLONE_DROPPED_KEYS, then name and label set. The list is ['_diagnostics', '_draft'] (METADATA_READ_DECORATIONS) plus the seven MetadataProtectionFields keys (_lock, _lockReason, _lockSource, _provenance, _packageId, _packageVersion, _lockDocsUrl) plus packageId, managedBy, protection, adminScope, isDefault. No facet is enumerated — ADR-0126 §7.1 "whole-definition copy, never param-list assembly" holds, and the deep-equality pin against the source minus the exported list makes a dropped facet red. The two keys the ruling names, adminScope and isDefault, are on the list; the provenance and package keys execute "no linkage to the base" (§7.1, §9). The response is { permissionSet, notice } with no ancestry field, and the pin asserts the source name is absent from the copy's JSON. The unit test's it('declares the mutation set and the dropped list once') pins _lock and _packageId by name but not _provenance or _packageVersion; the deep-equality case covers them, so the pin set is complete.
  8. The save — right in behaviour, wrong in its stated mechanism. protocol.saveMetaItem({ type: 'permission', name, item, parentVersion: null }): a null parent is sealedWrite = 'create' on main (protocol.ts:19910), the first-write pin, so a row written under the name between the catalog read and the save is refused rather than overwritten; no organization (the ADR-0131 D6 refuseOrganizationScopedWrite passes) and no package, so the copy is environment-authored. Wrong: the module doc (the comment above the save) and the PR body say the save "asks the one authoring rule every definition-write door asks (tenantAuthoredWriteRefusal) … and would be refused, loudly, if a provenance key ever rode across". On main that method returns null for every type but flow (protocol.ts, "Scoped to flow"); for permission a provenance key would be stripped silently by stripDerivedProvenance. The guarantee is carried by the dropped-keys list and its pin alone. The behaviour is right; the documented mechanism is not.
  9. The refusal envelope — right. 403 PERMISSION_DENIED, 401 at the floor, 400 VALIDATION_FAILED (validationFailure and the shape refusal), 404, 409 RESOURCE_CONFLICT, 503 SERVICE_UNAVAILABLE with the code declared for both the unbound and the failed catalog read, 501 when no protocol is composed, and a store refusal relayed with its own status and code through errorFromThrown. One divergence from the sibling, named: the catch falls back to 500 where catalog-activation.ts:272 falls back to 503 for an undeclared throw. Not a contract fault; the seat decides whether the two doors should agree.
  10. The route ledger and the censuses — right. One new server-only row carrying domain: '/security'; the activation row's note now says "one of the two"; the ledger census 87 → 88; the dogfood blind-spot census and the matrix docblock move with it (population/reachable/controls 88, blindSpot 0, keys 23). No SDK method, for the flow row's reason; the row says a client method reclassifies it to sdk. check-route-envelope declares permission-set-clone.ts { handBuilt: 0 }, which the module's code bears out (every answer is deps.success / deps.error / deps.errorFromThrown). The two dispatcher-error-vocabulary rows classify required / invalid_type as the flow rows do.
  11. The published docs — one page is broken on this head: WRONG, and the FAIL reason. content/docs/permissions/permission-sets.mdx (the new section and the pointer) and content/docs/concepts/metadata-lifecycle.mdx (the permission row names the door and says the refusal does not name it yet, which agrees with 6110777865 assigning that prose to stage 8) are right. content/docs/permissions/system-context.mdx on this head carries the three merge-conflict marker lines (the "ours" line, the equals line, the "theirs" line) at lines 370–374, inside the census table, with two "Files containing at least one elevation read" rows between them (58 and 57). The diff shows them (its lines 142–147), and the file read at ?ref=2bc6c13c… confirms them. The last commit, 2bc6c13c61 "recount the census after the merge", is where they land. A published docs page would render the markers or fail to build; Build Docs on this head is failure, and no other PR in this card's history with docs changes has that job red. check:system-context-census reads the first matching row by regex (scripts/check-system-context-census.mjs:1037), so it may stay green while the page is broken — the gate is not the reader of record here; the build is.
  12. Landing zone — right. The ruling says "services and rest"; the amendment 6111348289 names the model (POST /automation/:name/clone). Both precedents that produce this family — the flow clone and the catalog activation door — live in the runtime dispatcher, and the REST server mounts nothing under this path; the dev's placement follows the measured family. The dev's Q2 (A: server-only, no SDK method) is right for the same reason.
  13. Scope lines — held. clone_permission_set and ActionParamSchema.carryOver are untouched (stage 8). No packages/spec/src path moves. The dogfood pin deletes a packaged set's sys_permission_set rows under a system context inside its own booted temp database; that is measurement setup, not a product deletion, so the card's "deleting existing rows is C7's" line is not crossed.

② Semver level

  • What the diff publishes: one new HTTP route on @objectstack/runtime (published, private: false, 17.7.0) and the new module domains/permission-set-clone.ts with its exports; a ledger row; docs. packages/qa/dogfood and scripts/ are not published; content/docs is not a package. No other published package's src moves, and no packages/spec/src path is touched, so no spec changeset is owed.
  • The changeset: .changeset/15204-permission-set-clone-door.md, "@objectstack/runtime": minor — right for an additive route. Its sentences hold against the diff: never reads a sys_permission_set row (the double throws, the pin deletes); create-only (parentVersion: null); the dropped list as stated; isSystem bypasses; anonymous 401; the refusal table; "No existing route changes" (only the activation row's ledger NOTE moved, not its route). Check Changeset is success on this head.
  • Clause-②: the PR body's second line and the changeset both read Clause-②: yes (widening). Right: a path that accepted nothing now accepts { name, label }, and nothing accepted before is refused now.

③ Boundary flags

  1. Dev OQ1 — the locked-base refusal prose still names the data-door clone (A: leave to stage 8). Answered: A holds. 6110777865 carries packaged-base-regime.ts:198 and packaged-permission-set-lock.ts:343 to stage 8, and the metadata-lifecycle row states the gap in the meantime. Nothing owed here.
  2. Dev OQ2 — route placement in the runtime dispatcher, server-only, no client method (A). Answered: A holds (①.12). Re-open only when an app repository asks for a programmatic clone; the ledger row already says what then changes.
  3. Dev deviation — objectui comments NOT MEASURED, the dispatch's quotes executed verbatim. Closed by this record: the ruling 6097763840 was read directly, and its Q1 → A sentences ("copies the whole definition under a new name and drops the keys the server declares (adminScope, isDefault)"; "no linkage to the base"; the flow precedent as the model) are the behaviour measured in ①.5–①.8.
  4. Dev deviation — open PRs not searched. Covered by the head's two claim guards (No other open PR may claim the same issue / same single-writer path), both success.
  5. Dev deviation — the mid-run merge of main and the census recount. This is where the defect lives. The merge commit 6d4ffaea50 resolved one census conflict by count; the recount commit 2bc6c13c61 left the conflict markers in system-context.mdx (①.11). Escalated as the FAIL reason, with the remedy: one patch round that removes the three marker lines, keeps exactly one "Files containing at least one elevation read" row (58 if gen:system-context-census on the merged tree agrees), runs the docs build (Build Docs' own command) and check:system-context-census locally, and pushes. The record is re-rendered on that head; nothing in ① or ② outside item 11 and the two text fixes below needs to move.
  6. Not flagged by the dev — escalated to the seat: the door's accept set under a wall is /meta's. No posture rung is asked, by design (①.4), and domains/meta.ts asks none either, so under group / isolated an organization-scoped manage_metadata holder — the leak activation-gate.ts records in its header, which is why the §5 gate exists for switches — can create an environment-wide permission item through this door exactly as through PUT /meta/permission/:name today. ADR-0131 D3's "creation through Setup or the API is refused with a message naming the posture and the capability" is not executed here, and the card's walled-creation refusal pin (triage pointer 6070695107) has no stage. This is not a widening against /meta and the ruling's quoted sentences are silent on it, so it is not a FAIL reason of this record; the seat should say whether CD inherits /meta's posture-less accept set (as built, mirroring the flow precedent) or whether the D3 refusal is owed on the catalog doors before 6b-2 lands.
  7. Text fixes owed in the same patch round (no behaviour moves): the tenantAuthoredWriteRefusal sentence in the module doc above the save and in the PR body (①.8), and the 400 message's "or underscore" (①.2). Optional, the seat's call: align the catch's fallback status with the sibling door (①.9).
  8. Gates pending at render: Lint & Repo Gates and Test Core (3/6) were in_progress at 19:13Z; their conclusions are read on the next head in any case, since the patch round above moves it.

Implemented-by: session_01999rKpwstpg8Jysr6vN6JC
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 61449d236a51cac089b5d331cf301f852f8896dc
Local-runs: none

Delta round. The previous head 2bc6c13c6132c07d2952e554727282b0783c52eb got FAIL 6112729875 (one cause: committed merge-conflict markers in content/docs/permissions/system-context.mdx; two text errors owed in the same round). This record is a full same-form review of the new head; where ① adopts a passing judgment of 6112729875 it says so by number.

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 main at this head; the compare 2bc6c13c61...61449d236a; the 14 files as they stand at 61449d236a; on main: ADR-0126 §7.1 / §9 and ADR-0131 D3 / D4, identifiers.zod.ts (SnakeCaseIdentifierSchema), permission.zod.ts:817, metadata-protocol/src/protocol.ts (tenantAuthoredWriteRefusal, stripDerivedProvenance, saveMetaItem), packaged-base-regime.ts, the flow precedent (domains/automation.ts, flow-clone.ts), domains/catalog-activation.ts, lint.yml and ci.yml, scripts/check-system-context-census.mjs; the check-runs on this head, read at 2026-10-11T20:07Z. Branch claude/issue-15204-clone-door, cloud dev session_01999rKpwstpg8Jysr6vN6JC.

Gate verdicts on this head: 42 check-runs, 38 success, 4 skipped, 0 failure, 0 pending. Build Docs is success (completed 19:26:14Z — the job that was red on the previous head). Lint & Repo Gates is success (19:56:56Z; it was pending at the previous render), and that job is the one that runs node scripts/check-system-context-census.mjs --self-test then the census itself (lint.yml:383–384, no paths filter). Test Core (3/6) is success (the other pending one), as are Test Core 1/2/4/5/6 and the aggregate, all three Dogfood Regression Gate shards and their aggregate, Dogfood Verify CLI, Temporal Conformance, Build Core, the five type-check lanes, Spec property liveness, Check Documentation Links, Governed Surface Queue Guard, and Check Changeset and the four claim guards twice each (a second run of each fired at 19:55Z after the dev's PR-body patch; every run is success). The 4 skips: Console Pin Gate and Packed-tarball smoke (opt-in) are roster skips; Auto Label and Check PR Size each have a success from the 19:22Z run and a duplicate-trigger skip from the 19:55Z run. Nothing is still running; no pending gate to name.

① Derived judgments

The delta (brief item a) — right, and it is the whole change. compare/2bc6c13c61...61449d236a: ahead_by 1, behind_by 0, one commit 61449d236a (parent 2bc6c13c61), three files: content/docs/permissions/system-context.mdx (−4 lines: the ours line, the equals line, the 57 row and the theirs line go; the 58 row between them stays), packages/runtime/src/domains/permission-set-clone.ts (+9 −5: the 400 message and the comment above the save), packages/runtime/src/domains/permission-set-clone.test.ts (+9 −2: the 400 pin). No other file moves; main was not merged this round (the dev says so; GitHub reads the PR mergeable: true, mergeable_state: clean against the current main). The PR's file list is the same 14 files as before (+906 / −21).

  1. The FAIL cause is gone (brief item b) — right. All 14 files read at ?ref=61449d236a and searched for a line beginning with the seven-less-than, the seven-equals or the seven-greater-than marker: zero hits. system-context.mdx carries exactly one Files containing at least one elevation read row (:370, value 58). The page's companion sentences agree with themselves: "8 of the 58 anchored files", "read at 124 distinct sites across 20 packages", "published contract with 124 read sites in 20 packages", the decomposition rows 130 / 124 / 121 / 3 / 20 / 58 / 106 / 8. The census that governs is the gate's: check-system-context-census.mjs compares the table-files row (:1037) and the "of the N anchored files" denominator (:1061) each to c.files.length on the tree, and Lint & Repo Gates runs it on this head and is green, so the row's 58 is the census's 58 (the dev's local run reads the same: 124 sites, 20 packages, 58 files). Build Docs, the reader that caught the markers, is green on the head. Pre-existing and not this PR's: the page's numbered table has no row 14 (rows 1–13, 15–64); the diff does not touch it and the gate does not count rows by number.
  2. Text fix one, the provenance comment (brief item c) — right against main. The new comment above the save says the save is NOT what keeps the source's provenance off the copy; that tenantAuthoredWriteRefusal judges flow writes only and answers null for permission; that the provenance strip is silent; and that the guarantee is PERMISSION_SET_CLONE_DROPPED_KEYS plus the unit pin. On main: protocol.ts:16823–16826 — if (singular !== 'flow') return null; as the method's first decision, under a docblock reading "Scoped to flow … for those this returns null"; saveMetaItem asks it at :19912 and then applies stripDerivedProvenance at :19921, a silent strip of DERIVED_PROVENANCE_KEYS = ['_packageId', '_packageVersion', '_provenance'] (:1855, :1874). The PR body's bullet "Writes the copy through the metadata door" now says the same in the same terms. The earlier record's ①.8 "wrong in its stated mechanism" is closed; its "right in behaviour" stands (parentVersion: null is sealedWrite = 'create' at :19910; refusePackagedBaseOverride is asked at :20081).
  3. Text fix two, the 400 message and its pin (brief item c) — right against the schema. SnakeCaseIdentifierSchema on main (packages/spec/src/shared/identifiers.zod.ts:130–137) is .min(2) then .regex(/^[a-z][a-z0-9_]*$/), and PermissionSetSchema.name is that schema (permission.zod.ts:817). The message now reads "start with a lowercase letter, then lowercase letters, digits and underscores, at least 2 characters (e.g. 'sales_rep_copy')": every clause is the schema's, the leading-underscore offer is gone, and the example passes the regex. The pin adds '_copy' as a refused name (400 VALIDATION_FAILED, nothing saved) and asserts the message contains "start with a lowercase letter" and neither "underscore)" nor "letter or underscore". The field-level message still relays the schema's own issue text, which is the one declaration's wording, not a second rule. The earlier record's ①.2 "one message wrong" is closed.
  4. Nothing else in ① moves on the new head (brief item d). The delta changes no behaviour: the match predicate, the authority ordering, the catalog read, the 409, the copy, the dropped list, the save shape, the refusal envelope, the mount, the ledger row and the censuses are byte-identical to the previous head. Adopted from 6112729875 by number, each re-read against the diff and the ruling's sentences this round: ①.1 (route and mount; nothing in packages/rest under /security/permission-sets), ①.3 (match predicate with no depth bound, depth other than 3 refused 400 — the flow precedent's own shape, automation.ts:519), ①.4 (authority is metaWriteCapabilityVerdict({ operation: 'save' }), asked first; not the §5 activation gate — automation.ts:534–535 says why for the flow clone), ①.5 (source read through securityCatalogReaderOf(ql).resolve('permission', name), registry first; no row read, the unit double throws on every find, the dogfood pin deletes the source's rows), ①.6 (new name mandatory: 409 for a taken name, the source's own included; create-only save behind it), ①.7 (the copy is structuredClone(definition) minus the list, the list is ['_diagnostics', '_draft'] + the seven MetadataProtectionFields keys _lock, _lockReason, _lockSource, _provenance, _packageId, _packageVersion, _lockDocsUrl + packageId, managedBy, protection, adminScope, isDefault; re-confirmed on main's metadata-read-decorations.ts:48 and metadata-protection.zod.ts:81–130), ①.9 (the envelope; the catch's 500 fallback where catalog-activation.ts:272 falls back to 503 — unchanged, see ③), ①.10 (ledger row server-only, census 87 → 88, dogfood census moved with it, check-route-envelope { handBuilt: 0 }, the two dispatcher-error-vocabulary rows), ①.12 (landing zone: the measured producer family — flow clone and catalog activation — lives in the runtime dispatcher), ①.13 (scope lines held: clone_permission_set and ActionParamSchema.carryOver untouched; no packages/spec/src path; the dogfood row deletion is measurement setup in a temp database). ①.11's two right pages (permission-sets.mdx section and pointer; metadata-lifecycle.mdx permission row naming the door and saying the refusal does not name it yet) stand; its wrong page is item 1 above, now right.
  5. The diff against the ruling's quoted sentences (6097763840, Q1 → A) — held, re-judged this round. "It copies the whole definition under a new name": clonePermissionSetDefinition enumerates no facet, deep-copies, sets exactly name and label; a taken name is 409 and the save is create-only. "Drops the keys the server declares (adminScope, isDefault)": both are on the one exported server-side list; the client sends only { name, label }, so no client deny-list exists (the ruling's "second home for a security policy" cannot form). "No linkage to the base" (ADR-0126 §7.1 third bullet; §9 "ancestry is deliberately not tracked"): the provenance envelope and the package keys are dropped, the response is { permissionSet, notice } with no ancestry field, and the pin asserts the source name is absent from the copy's JSON. "The flow precedent POST /automation/:name/clone already exists": the door is that precedent's twin — same match shape, same whole-definition rule, same spec-derived envelope drop, same 409 status constant, same notice pattern. "Lands as an objectstack stage before stage 8 … Clause-②: yes": landed in packages/runtime, judged right in ①.12 above; Clause-②: yes (widening) on the PR body's second line and in the changeset. ADR-0131 D4 ("referenced by name, resolved registry-first"; "Studio refuses a name a managed package holds"): the source and the target are both resolved by name through the registry-first reader, and a managed name is 409.

② Semver level

  • What the diff publishes: unchanged from 6112729875 ② — one new HTTP route on @objectstack/runtime (published) and the new module domains/permission-set-clone.ts with its exports; a ledger row; docs. packages/qa/dogfood and scripts/ are not published; content/docs is not a package; no packages/spec/src path moves, so no spec changeset is owed. The delta adds nothing published: a 400 message's wording and a comment.
  • The changeset: .changeset/15204-permission-set-clone-door.md, "@objectstack/runtime": minor — right for an additive route; untouched by the delta. Its sentences still hold on this head, the one the delta touches included: "400 VALIDATION_FAILED for … a name failing the name rule" is what the door does, and the message now states that rule as the schema has it. Check Changeset is success on this head, in both of its runs.
  • Clause-②: Clause-②: yes (widening) — PR body line 2 and the changeset agree. Right: a path that accepted nothing accepts { name, label } from a manage_metadata holder; nothing accepted before is refused now.

③ Boundary flags

  1. Dev OQ1 — the locked-base refusal prose still names the data-door clone (A: leave to stage 8). Answered: A holds, as 6112729875 ③.1 found and 6110777865 rules (packaged-base-regime.ts:197–198 and packaged-permission-set-lock.ts:343 are stage 8's, re-read on main this round). The metadata-lifecycle.mdx row names the door and the gap. Nothing owed here.
  2. Dev OQ2 — route placement, server-only, no client method (A). Answered: A holds (①.4 above adopting ①.12). Re-open only when an app repository asks for a programmatic clone; the ledger row says what then changes.
  3. Dev deviation — objectui comments NOT MEASURED, the dispatch's quotes executed verbatim. Closed again by this record: 6097763840 was read directly and its sentences are the behaviour judged in ①.5.
  4. Dev deviation — open PRs not searched. Covered by the two claim guards, each success twice on this head (19:22Z and 19:55Z).
  5. Dev deviation — the mid-run merge and the census recount (the previous FAIL). Closed: the markers and the duplicate row are gone (①.1), the census gate and the docs build are both green on the head. The dev's new out-of-scope finding is right and is not this PR's to fix: on 2bc6c13c6 check:system-context-census exited 0 with the markers present, because its table-files pattern takes the first matching row; only Build Docs caught it, and only because the file was a docs page. On main neither lint.yml nor ci.yml has a committed-marker scan (searched this round: no step names one). Escalated to the seat as a carrier decision, not a FAIL: a repo-wide marker check (one grep step in Lint & Repo Gates, or a --markers refusal in the census script) would have refused 2bc6c13c6 outright. Dedupe words: the dev's.
  6. main not merged this round. The dev reports origin/main five commits ahead of the last merge (6d5191e69) and did not merge. GitHub reads the PR mergeable_state: clean. No gate on this head needs the merge; if the landing-operations §A round wants one, the three files the delta touches (system-context.mdx is a census page that main has moved before) are where a re-count would land, and the census gate is the reader.
  7. The ③ 6 escalation (the door's accept set under a wall) — answered by the seat in 6112750171; nothing is owed by this PR. The seat rules that CD inherits the metadata door's accept set as is (the ruling gives the door "the metadata door's" authority; a posture refusal only on CD would be a second home for D3), and opens stage W (claude/issue-15204-wall-catalog-refusal, measure first, Clause-②: yes (narrowing)) to put the ADR-0131 D3 walled-creation refusal at "the metadata door's write path, so every door that saves through saveMetaItem inherits it". Judged against this diff: CD saves through protocol.saveMetaItem with no organization and no package, the same request shape /meta sends, so a refusal placed in saveMetaItem's write path reaches CD with no CD edit — the seat's item 4 is the right home for it to reach both doors. One boundary for W, recorded here so it is not re-derived: a refusal placed at the HTTP layer of domains/meta.ts would NOT reach CD, and W's measurement item 2 already names CD on this branch as a door to measure. W is the carrier; CD owes nothing and claims nothing about posture (its ledger row says "NOT the ADR-0126 §5 activation gate", which stays true).
  8. The optional item of 6112729875 ③.7 (the catch's 500 fallback vs the sibling's 503). Untouched, as the patch round instructed; the seat's call, not a contract fault. Named so the delta is read as complete rather than as having skipped it.
  9. Gates pending at render: none. Every check-run on 61449d236a is completed.

Implemented-by: session_01999rKpwstpg8Jysr6vN6JC
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants