Skip to content

feat(security): lock package-declared permission sets at the save door; clone to customize - #11702

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11513-lock-packaged-permission-set
Aug 24, 2026
Merged

feat(security): lock package-declared permission sets at the save door; clone to customize#11702
os-zhuang merged 1 commit into
mainfrom
claude/issue-11513-lock-packaged-permission-set

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#11513

Maintainer ruling of 2026-08-24 (comment 5394202746), recorded verbatim and untranslated:

同意 第一步(创业阶段,Salesforce 式)

Step 1 of the mainstream-platform comparison: lock the base, clone to customize. This is the server half; the UI half (lock rendering + Clone button in Studio) is filed separately in objectui and is blocked on this landing.

The hole this closes

createPermissionSetWriteThrough translated every non-system data-door write on sys_permission_set into a saveMetaItem, and deliberately left the refusal to the producer (the metadata protocol's ADR-0005 tier gate) — the disposition recorded in permission-set-projection.ts's own header under ADR-0094 D5-R.

That gate is exactly what the documented OS_METADATA_WRITABLE=permission operator hatch switches off. With the hatch open there is no refusal at all: the save mints a sys_metadata overlay of a package-declared set, and reconcilePermissionSetProjection re-projects that overlay onto the record on every boot, unconditionally, keyed only on overlay presence — so the fork wins forever, no expiry, no diff, no signal. The card measured customized reading 0 for two weeks while an overlay froze a set.

The header's disposition is therefore retired in this PR, in the header itself, with the two measured reasons: the producer's gate is switchable off, and its message ("the type has not opted into per-org overlay writes") names nothing an admin can act on.

What lands

  1. The save door refuses. A data-door insert or update targeting a package-declared set throws NOT_OVERRIDABLE / 403 before the metadata write, with a message that names the sanctioned path: clone it.
  2. Clone-to-customize already worked and is now pinned end to end: the clone is managed_by: 'admin' with no package_id (no upgrade linkage), the base row is byte-identical afterwards, no overlay of the base is minted, and the clone itself is freely editable.
  3. Detection reading for forks that already exist — packaged-permission-set-overlay-detection.ts, wired at boot beside the drift diagnostic. Count and names, warned loudly, and the line says outright that nothing was reaped.

Not chartered, not done: the ServiceNow-style explicit overlay layer (item 4 of the ruling) — no code, no card. ⛔ No reap, merge or migration of existing forks, under any framing: the reading has no write verb and its double exposes none, so a reap added later fails with a TypeError rather than passing quietly.

⭐ The fence: the provenance read cannot fail open

The lock turns on one question — is this set package-declared? — so a read that answers "not package-declared" when it merely failed to find out would accept exactly the save this feature exists to refuse.

#11518 (open, unclaimed, ⛔ not fixed here) is that read: buildExistingByName's unscoped page cap limit: names.length truncates once a name can carry more than one row, and a truncated page reads as absent.

The lock does not use it. Provenance comes from the engine SchemaRegistry — the same source readDeclared, readDeclaredBody and permission-set-overlay-discard.ts's eligibility test already use, so this plugin keeps one spelling of "package-declared". That read is an in-memory array: no page, no cap, no $in, no driver. Two controls prove it structurally rather than asserting it:

  • control A builds the exact multi-row shape buildExistingByName's UNSCOPED page cap truncates once a name can have more than one row — and a truncated page reads as "absent", which inserts #11518 truncates on, in a double whose findhonours limit (the sibling double in permission-set-projection.test.ts ignores it, so the trap cannot even be expressed there), shows the truncation is live, and pins that the refusal still fires;
  • control B makes every name-keyed page read over sys_permission_set fail outright while by-id reads keep working, so the middleware still reaches the question. The verdict is unchanged — only possible if the question was never asked of that table. If provenance is ever re-routed through a name-keyed page, that test goes red instead of silently inheriting the fail-open.

Fail-closed on ambiguity: classifyPackagedPermissionSet has three verdicts, not two. If every artifact source that exists failed to answer — or none exists — the verdict is unknown and the save is refused. (Measured on main: on that path today the save lands and then raises an unrelated error, so a caller reads a failed save that in fact succeeded.)

⭐ What is deliberately NOT narrowed — each pinned as a control

Clause ② applies (the accept set narrows on a write door), so the counter-direction is pinned as hard as the refusal. Pinning only the refusal lets an implementation that refuses everything score green.

  • ordinary org-owned sets stay fully editable (update and insert);
  • activate / deactivate still write their column — a bare { active } patch is row state, not a customization (permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的 active 存储列喂进了 #4001 之后严格化的 permission spec #4669), and a lock that swallowed it would break the surface it protects;
  • a managed_by: 'package' row with no artifact behind it keeps editing in place — published through the metadata door (ADR-0070), materialized by the ADR-0086 P2 path. That is ADR-0094 D5-R's surviving allowRuntimeCreate neighbour. The ruling's parenthetical calls the target "a row carrying package provenance", and managed_by is the obvious reading of that — but it is measurably the wrong fact in both directions: too narrow (the provenance_skip shape is a package-declared set whose row's managed_by was never 'package') and too broad (the case above). Provenance is read from the artifact, exactly as the discard action reads it.

Why this is not the Prime Directive #8 parallel allowlist

The retired header text said pre-empting the producer "would need a second copy of isArtifactBacked". It does not: the lock reads the engine SchemaRegistry, which is already this plugin's single spelling of the question in three places. It never reads managed_by. ENV_PROJECTION_MARKER was moved into the lock module and imported back by the projection module, so the constant has one home rather than two — the lock decides "packaged" partly by the absence of that marker.

readDeclared is deliberately not reused, and that is a strictness difference rather than a second spelling: it normalizes a failed registry read to [], which is right for a boot seeder and exactly wrong for a write door, where [] reads as "no package declares this" and accepts.

The detection reading does not depend on customized

upsertEnvPermissionSet computes the flag as existing.managed_by === 'package' ? !!customized : false, so on the confounded shape the card measured it is forced false while an overlay really is shadowing the row. A reading built on it would report zero forks on the one environment that had one. sys_metadata is read directly, the way #9952's drift_status overlay-shadow branch does, and the confounded row is a pin rather than a footnote.

⛔ Making customized itself correct is not chartered (the card lists it among candidates; the ruling did not take it up) and nothing here reads or writes that column.

It is also not the drift diagnostic: drift reports a set whose grants already differ from the artifact, so an overlay that currently happens to match is invisible to it — while that fork is entirely real and freezes the set the moment the package ships its next version.

Test-first, not ablation

Every pin was written before any source edit, on an otherwise-unmodified tree, with the expected failure signature predicted in writing first. No restore in the loop, so no restore could silently fail. No ablation was used anywhere in this change.

Round 1 (write-door pins, importing only existing modules) — measured red: Test Files 1 failed | 77 passed (78), Tests 6 failed | 1480 passed (1486). The six failures were the six intended ones; every control was green before and after.

One prediction was wrong and is recorded as wrong: I predicted pin 5 would fail with "promise resolved instead of rejecting". It did reject — with the harness's own getMetaItemLayered throw escaping uncaught from projectPermissionMutationaftersaveMetaItem had already written the overlay. The direction of the prediction (accepted, not refused) held; the signature did not, and the real behaviour is worse than the predicted one.

Round 2 (detection pins) — measured red as Error: Cannot find module './packaged-permission-set-overlay-detection.js'. That is a collection failure, weaker evidence than a per-test red, and it is recorded as such rather than dressed up.

Verification

Union run after the final commit, at 535abd544d:

whatverdict line
pnpm --filter @objectstack/plugin-security testTest Files 79 passed (79) · Tests 1497 passed (1497)
pnpm --filter @objectstack/plugin-security typechecktsc --noEmit, exit 0
pnpm lint (repo-wide eslint . --no-inline-config)exit 0 — the whole farm, not a narrowing
check:engine-double-contractOK — 400 pinned, 133 in the DEBT ledger, 2 exempt
check:i18nOK (9 package(s) — all bundles in sync, no undeclared authoring keys)
check:nul-bytesOK (scanned 6526 text file(s) … no raw ASCII control bytes)
dogfood two-doors-permission + showcase-permission-projectionTest Files 2 passed (2) · Tests 13 passed (13)

Gates derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path arguments, clean tree, after the final commit); all 17 path-matched families plus the convention-triggered set ran green locally. Exit codes were captured before any pipe throughout.

⚠️Ratchet note:engine-double-contract.baseline.json (shrink-only) is unchanged — verified with an empty git diff --stat. The two new rows land in engine-double-contract.pinned.json, the coverage ledger that legitimately grows, written by the gate's own --write. The new double routes update and delete through assertEngineUpdateDispatch / assertEngineDeleteDispatch.

Two prerequisite faults were hit and are reported as not measured, never as reds: check:i18n first answered PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked (re-measured green after building the CLI), and the first dogfood attempt failed 81 files on Failed to resolve entry for package "@objectstack/connector-mcp" (re-measured green after building dogfood's dependency closure).

What this does not close, stated rather than left to be found

  • the restore leg of the write-through still re-authors a restored record's body into metadata without the lock. It runs after the record is already back, where a throw would strand the caller with a healthy-looking row and no way to hear about it — the reason its neighbours catch rather than throw. Reaching it needs a trashed record for a package-declared name, which the delete path does not produce (it resets instead). Left untouched deliberately; naming it here so a reviewer can contest it rather than discover it.
  • the overlay sweep in the detection reading is a page capped at 1000 — the same cap and the same read the reconciler and the drift diagnostic already use. A truncation there under-reports the listing. That is tolerable for a diagnostic (it costs an operator a name) and would not be on the write door, which is why the lock's provenance read is not a page at all. Stated in the module header too.
  • Observation: package permission sets are materialized with a tenant-less system context, so sys_permission_set rows land organization-less #8672, cited on the card as "open — org-less materialization, same file", could not be read: it 404s as both an issue and a PR in this repo, with both instruments demonstrably working on other numbers. Treated as unmeasured, not "does not exist" — most likely a cross-repo citation missing its prefix. Nothing in this change needed it.

Adjacent in-flight work

security-plugin.ts is also touched by PR #11639 (#11343, plugin-security). The regions are ~100 lines apart and both are purely additive (my import sits beside runPermissionSetDriftDiagnostics's at the top; my boot call sits beside the drift call at ~3200, theirs is the replay middleware at ~3320). scripts/engine-double-contract.pinned.json also takes an additive row from each, at different sort positions. If either conflicts, the remedy is merge, never rebase, and the ledger is regenerated with the gate's own --write.

Review posture

Draft, and it stays draft. Clause ② is yes — this narrows the accept set on a write door. CONTRACT_REVIEW_TIER is claude-fable-5 and this seat is below it, so needs:contract-review stays on the card: not flipped ready, no auto-merge, not merged.

⛔ Zero packages/spec ownership was needed. NOT_OVERRIDABLE is a StandardErrorCode already emitted for this exact condition by the metadata protocol's tier gate — one condition, one vocabulary — so no ledger entry is minted and check:error-code-casing is green. The message deliberately does not open with [Security] Access denied, which is a matcher that would re-flatten the code to PERMISSION_DENIED on the wire (see errors.ts).


Generated by Claude Code

…r; clone to customize
Maintainer ruling 2026-08-24 (verbatim, untranslated):
「同意 第一步(创业阶段,Salesforce 式)」 — step 1: lock the base, clone to
customize.
A Studio/API save targeting a package-declared permission set is refused at the
server, with a message that names the sanctioned path. Previously the data door
translated the write and left the refusal to the metadata protocol's ADR-0005
tier gate — which is exactly what the documented OS_METADATA_WRITABLE=permission
operator hatch switches off, so with the hatch open there was no refusal at all
and the save minted a sys_metadata overlay of a packaged set that boot
reconciliation then re-projected on every boot, forever.
Provenance is decided from the engine SchemaRegistry — the one source this
plugin already calls "package-declared" — never from a name-keyed page over
sys_permission_set, whose unscoped cap truncates into a false "absent" (#11518,
untouched here). Fail-closed: a read that cannot answer refuses the save.
Not narrowed: ordinary org-owned sets, the activate/deactivate column writes,
and a managed_by:'package' row with no artifact behind it (ADR-0094 D5-R's
surviving allowRuntimeCreate neighbour). Each is pinned as a control.
Existing forks get a detection READING at boot — count and names, warned loudly,
reaping nothing. It reads sys_metadata directly rather than `customized`, which
is forced false on the exact confounded shape the field report measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 30 documentable anchor(s).

19 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 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/index.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 14 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 4ceae8ab0b4c6649e0fa6faf591b4307b17c021dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b9570a0a0538ab5e6a862cc9e66a71e2ba1ffc91 — the merge of head 535abd544d3d785a78c6d72a39cc89b8b18f3e69 into base 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d, 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 b9570a0a0538ab5e6a862cc9e66a71e2ba1ffc91 && git checkout b9570a0a0538ab5e6a862cc9e66a71e2ba1ffc91
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d 535abd544d3d785a78c6d72a39cc89b8b18f3e69 && git checkout -B drift-repro 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d && git merge --no-ff 535abd544d3d785a78c6d72a39cc89b8b18f3e69
node scripts/docs-audit/affected-docs.mjs --json 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d

⚠️ 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 4ceae8ab0b4c6649e0fa6faf591b4307b17c021d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — substance PASSES; ⛔ held on contract review, not on anything in the work

domain:services seat (session session_01APWX2AwT3a4xDcjPCe8bk4). Clause-② is yes (the accept set narrows on a write door) — needs:contract-review stays on #11513, CONTRACT_REVIEW_TIER is claude-fable-5, and this seat is below it. Not flipped ready, not armed, not queued. The PR's own review-posture section says the same thing, which is the right instinct.

⭐ The fence I was most worried about was answered structurally — and better than I asked

I dispatched: "ensure your provenance check cannot inherit #11518's fail-open… state which read you used and prove why it cannot."

The answer is that the lock does not use that read at all. Provenance comes from the engine SchemaRegistry — "an in-memory array: no page, no cap, no $in, no driver" — the same source readDeclared, readDeclaredBody and permission-set-overlay-discard.ts already use, so the plugin keeps one spelling of "package-declared".

And it is proved by two controls rather than asserted:

⭐ Control B is the part that exceeds the brief: it does not merely establish today's behaviour, it goes red if provenance is ever re-routed through a name-keyed page. A future regression into the fail-open cannot land silently. That is the difference between proving a fact and installing a guard.

Fail-closed on ambiguity, with three verdicts not two: if every artifact source failed to answer — or none exists — the verdict is unknown and the save is refused. Measured on main today, that same path lands the save and then raises an unrelated error, so a caller currently reads a failed save that in fact succeeded. A pre-existing defect found in passing and closed by construction.

⭐ The trap that would have shipped a subtly wrong lock

The ruling's parenthetical names the target as "a row carrying package provenance", and managed_by is the obvious reading of that. It is measurably wrong in both directions:

  • too narrow — the provenance_skip shape is a package-declared set whose row's managed_by was never 'package';
  • too broad — a managed_by: 'package' row with no artifact behind it, published through the metadata door (ADR-0070) and materialized by the ADR-0086 P2 path, must keep editing in place.

So provenance is read from the artifact, exactly as the discard action reads it, and managed_by is never consulted. Taking the parenthetical literally would have produced a lock that refused the wrong rows and admitted the wrong rows, with tests that all passed.

Verified independently by this seat

Re-checked with three-dot semantics after first running a two-dot diff that was contaminated by main-only commits — correcting my own check rather than quoting the contaminated one:

  • exactly 9 files, matching the PR's own count; all in plugin-security plus its changeset and the pinned ledger;
  • engine-double-contract.baseline.json (shrink-only) — unchanged, empty diff;
  • engine-double-contract.pinned.json+10 insertions, 0 deletions (two rows in the coverage ledger that legitimately grows, written by the gate's own --write; nothing lost);
  • zero packages/spec paths — the fence held.

Discipline worth naming

  • One prediction was wrong and is recorded as wrong. Pin 5 was predicted to fail as "resolved instead of rejecting"; it did reject — via the harness's own throw escaping uncaught aftersaveMetaItem had already written the overlay. Direction held, signature did not, and "the real behaviour is worse than the predicted one." Reporting that is worth more than a prediction that happened to match.
  • Round 2's red was a collection failure (Cannot find module …) and is recorded as "weaker evidence than a per-test red… rather than dressed up."
  • Two prerequisite faults (PREREQUISITE NOT MET on check:i18n; a dogfood resolve failure) were reported as not measured, never as reds, then re-measured green.
  • Observation: package permission sets are materialized with a tenant-less system context, so sys_permission_set rows land organization-less #8672 handled exactly as dispatched: "unmeasured, not 'does not exist'."
  • customized untouched — not chartered, and the detection reading explains why it cannot be built on it (upsertEnvPermissionSet forces it false on the confounded shape the card measured).
  • ⛔ No reap, merge or migration: the detection reading has no write verb and its double exposes none, so a reap added later fails with a TypeError rather than passing quietly. That is a structural refusal, not a promise.

Sequencing with #11639 — noted, not yet actionable

This PR flags that security-plugin.ts and engine-double-contract.pinned.json are also touched by PR #11639 (#11343). Checked: #11639 is in the merge queue, not yet merged (mergeable_state: clean), so no conflict exists right now. Since this PR cannot land before its contract review, #11639 will have merged by then and the remedy is the one already documented here — merge, never rebase, and regenerate the ledger with the gate's own --write. Both regions are additive and ~100 lines apart.

Stated rather than left to be found — and I am not contesting any of them

The restore leg of the write-through still re-authors without the lock (unreachable without a trashed record for a package-declared name, which the delete path does not produce); the detection sweep's 1000-row page can under-report the listing, which is tolerable for a diagnostic and is exactly why the write door's provenance read is not a page at all. Naming these in the PR so a reviewer can contest them rather than discover them is the right shape.


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM: docs-drift advisory — one passage worth a look, and the limit of what I could establish

domain:services seat. Read with git grep <ref> on b9570a0a05…, the merge commit the run itself named. Positive control first: 45 non-release pages name "permission set" on that ref, so the channel reaches them.

Unlike the other drift advisories this shift, this one is not a clean no-action, because this PR narrows an accept set — so prose describing what an admin can do is the class that can go false.

The passage

content/docs/permissions/permission-sets.mdx, "Declared ≠ enforced — diagnosing a frozen package set":

Overlay shadow. An active sys_metadata overlay for the set's name (from a Studio save made while overlaying was still permitted, or through the OS_METADATA_WRITABLE escape hatch) is re-projected onto the record on every boot, unconditionally…

What is unchanged and still accurate: the overlay-shadow mechanism itself (an existing overlay wins forever, regardless of what the package ships next), the Discard Overlay remedy and its refusal on non-package-declared sets, and the whole Provenance skip branch. This PR changes none of that — which matters, because those are the parts an operator diagnosing a frozen set actually acts on.

What may now be incomplete: the parenthetical describing how such an overlay comes to exist. After this PR a Studio/data-door save targeting a package-declared set is refused with NOT_OVERRIDABLEbefore the metadata write, so that route no longer mints one.

⚠️ The limit of my measurement — stated rather than papered over

I could not establish whether the OS_METADATA_WRITABLE route remains open through the metadata door directly, as opposed to through the sys_permission_set data door this lock guards. The PR describes the lock as sitting on createPermissionSetWriteThrough — the data-door write-through — ahead of the producer's ADR-0005 tier gate, which is what the hatch switches off. If the metadata door can still be written directly with the hatch open, the sentence stays true as written and there is no drift at all.

That is inferred from this PR's description, not measured — I did not read the metadata-door path. So I am flagging it as a question, not asserting drift.

Suggested disposition, for the contract reviewer or the author to settle (⛔ not for this PR to absorb, and not something I will decide):

  1. If the hatch's metadata-door route is still open → no doc change needed; this comment closes itself.
  2. If this PR closes the last route by which a new overlay of a package-declared set can be minted → that passage should gain a sentence saying so, as a docs-only follow-up, not a rider here. Widening a code PR into content/docs is not something I want on a Clause-② change.

⛔ The 4 release-owned pages were read-only and untouched. ⛔ Nothing here changes the review posture: this PR stays draft, needs:contract-review stays on #11513, not flipped ready, not armed.

(For future readers: that bot is symbol-anchored and precision-first (#9192), hand-written docs only — it lists a page because the page names a symbol the diff touched, and it cannot see whether prose went false. The passage above was found by aiming at the one failure class this diff creates, not by reading the row count.)


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Sequencing note closed: PR #11639 has merged, and this still merges cleanly

domain:services seat. My earlier review said #11639 was in the queue and the overlap was "noted, not yet actionable". It has now merged (13:09Z), so I measured rather than left it:

  • Both sides did change the two predicted filespackages/plugins/plugin-security/src/security-plugin.ts and scripts/engine-double-contract.pinned.json — confirmed by intersecting each side's changes against the shared merge base 266436a7f7.
  • A read-only git merge-tree against the new origin/main produces a clean tree (a08af26547), no conflicted paths. The prediction in this PR's Adjacent in-flight work section — additive, ~100 lines apart — holds.

No action needed now, and specifically ⛔ no rebase and no premature base update.

⚠️ One caveat worth carrying to whoever updates the base: a clean textual merge of engine-double-contract.pinned.json is not the same as a correct one. It is a generated ledger, and two additive rows merging cleanly by text can still differ from what the gate's own --write would emit (ordering, formatting). So when the base is brought forward, re-run check:engine-double-contract and regenerate with --write rather than trusting the merged text — which is exactly the remedy this PR already documented.

Review posture unchanged: still draft, needs:contract-review stays on #11513, not flipped ready, not armed. See also the #11703 sequencing question raised on the card — that, not this, is the open item on this change.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay

3 participants

@os-sam@os-zhuang@claude