Skip to content

fix(app-shell): Studio Access refuses a new permission set over a taken identifier and can discard a set's draft - #26916

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-m5noqq
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-m5noqq

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread

Fixes #26836

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:Studio「访问」页新建权限集前,先查已发布和草稿中的同名权限集(跨所有软件包);标识已被占用就拒绝、不写入,提示里点名那个权限集,属于本软件包时给出「打开它」。另外,打开的权限集若有待发布草稿,标题栏多一个「放弃草稿」。
  • 为什么改:原来的首写保护(If-None-Match: * + 草稿模式)只拦已有草稿,不拦已发布的同名权限集,于是新建会在线上权限集上叠一份空白草稿,发布软件包就会清空它的全部授权;Studio 里也没有办法放弃这份草稿。
  • 风险与回滚:低。只改 AccessPillar 一处的新建与放弃路径,沿用现有接口(列表、草稿列表、DELETE ?state=draft&package=);服务端、spec 不动。回滚即还原本 PR。
  • 先看:ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx 的 AccessPillar(doCreate 新增的占用检查、discardDraft、对话框 extra 里的拒绝提示);StudioDesignSurface.accessCreateTaken-26836.test.tsx。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx,预期 9 个测试全绿。
  • 需要你做的:无,席位落地。

What changes

  • Create over a taken identifier is refused before anything is written. AccessPillar's doCreate now reads the published permission sets and the draft headers across every package (client.list('permission'), client.listDrafts({ type: 'permission' }), the same unscoped pair the Interfaces pillar's create reads). A name either holds is refused in the dialog with a sentence naming the set (its label and identifier). When the set is this package's (on the rail), the dialog offers "Open “LABEL”", which closes the dialog and opens it; a set another package holds gets no open path, and the sentence says it is outside this package.
  • A fresh name is created as before: the draft save with mode: 'draft', packageId, ifNoneMatch: '*', surface: 'studio', the toast, and the new set opened.
  • Discard draft. An open set with a pending draft shows "Discard draft" in its header (beside Explain access), hidden on a read-only package. It confirms (one sentence for a published set, another for a set never published), then calls client.reset('permission', NAME, { state: 'draft', packageId }), the DELETE ?state=draft&package= door the metadata editor's discard uses. A published set goes back to its live body (the matrix re-reads it, as after a publish); a set never published leaves the rail. A refused discard is a toast with the door's message.
  • Six new studio namespace rows in @object-ui/i18n, en and zh. One root changeset: @object-ui/app-shell patch and @object-ui/i18n patch.
  • No packages/spec/**, no server change.

Tests

StudioDesignSurface.accessCreateTaken-26836.test.tsx, nine cases on the real AccessPillar and PermissionMatrixEditPage, over a client double that keeps published and draft rows per package and refuses a first-write pin over a draft row the way the door does:

  • create over a published id of this package: refused, save never called, no draft row, the sentence names the set, and "Open" opens it (its header and grant count);
  • create over a published id another package holds: refused, nothing written, no "Open";
  • create over a draft id: refused before the save;
  • a draft that lands between the check and the save: the save still carries ifNoneMatch: '*' and the door's 409 is shown, the other draft untouched (objectui#11773);
  • a fresh id: saved with the four options above, toast, rail row and header;
  • discard on a published set: reset called with { state: 'draft', packageId }, the matrix goes from 0 to 1 object granted (the live set), the set stays on the rail, the control goes away;
  • discard on a set never published: it leaves the rail and the remaining set opens;
  • a declined confirm discards nothing;
  • no discard control without a pending draft, nor on a read-only package.

Acceptance notes

  • The door half is not a server defect, so there is no domain:server child. The metadata door does what the spec declares: SaveMetaItemRequestSchema.parentVersion says a present null "asserts 'no current row of this lifecycle'" (on the REST door, If-None-Match: *), and the @objectstack/metadata-protocol changelog entry that opened the header says If-None-Match: * "with ?mode=draft for a draft … saves only where no row of that lifecycle exists". A draft-mode pin over a published row is therefore admitted by contract (it is how a client says "start a draft only if nobody has one"). Making the door judge every lifecycle would be a semantic change to that declared pin, beyond this card.
  • Race window of the client-side check: a set published (or created in another package) between the pillar's two reads and its save is not refused by the pin; the save then writes a blank draft over it, as before this change. A draft that lands in that window is still refused by the door (tested above).
  • The "Open" path exists only for a set on this package's rail; a set another package holds cannot be opened from this pillar, so the refusal there names it and asks for another identifier.

Verification

All at head 4dd29301, in a worktree off origin/main c4a4aefc.

New tests, ablation, type-check and nearby suites
  • pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx under scripts/pm/os-verify-lock.sh: Test Files 1 passed (1), Tests 9 passed (9).
  • Ablation (node scripts/ablation-replace.mjs, anchor if (publishedRow || draftRow) { replaced by if (false && (publishedRow || draftRow)) {, anchor x1 to x0): the three refusal cases go red (Tests 3 failed | 6 passed (9)); restored, blob equal to HEAD and git diff HEAD empty. The tests import the pillar from source, so no build sits between the mutation and the run.
  • pnpm --filter @object-ui/app-shell run type-check && pnpm --filter @object-ui/i18n run type-check under the lock: VERDICT command-exit 0 (app-shell's covers the test file).
  • Narrowed suites under the lock: every app-shell test file that names AccessPillar, the studio namespace or an engine.studio.access key (66 files), plus all of packages/i18n/: Test Files 155 passed (155), Tests 2159 passed | 13 skipped (2172). CI's Test UI runs the rest of app-shell.
  • pnpm run check:i18n-designer-parity (from ui/): "Every en row has a zh row, and every shared row carries the same placeholders." pnpm check:new-line-citations (from ui/): 0 new citation(s).
Derived dispatch gates (34/34)
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 34 commands; every one exited 0, the PR-body gates (check-closing-keyword-parity, check-adr-0087-registration, check-changeset-no-major) against this body.
  • pnpm --dir ui exec vitest run scripts/ under the lock: Test Files 178 passed | 2 skipped (180), Tests 5065 passed | 2 skipped (5067).
  • pnpm check:type-check-debt under the lock: 0 raw tsc error(s) total, none above its recorded number.
  • dispatch-gates --ran: 34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED.

Dogfood battery: not run, no ! in the title and no (narrowing) arm.


Generated by Claude Code

…en identifier and can discard a set's draft

The create's first-write pin asserts that no draft row exists, so a name
that derived to a published set's identifier wrote a blank draft over the
live set. The create now reads the published and draft sets first and
refuses a taken name, naming the set and offering to open it when it is
this package's. An open set with a pending draft can now discard it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jRu5CpdGJFtUFzPvUy
…r and the draft discard

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jRu5CpdGJFtUFzPvUy
Conflicts: the studio namespace en.ts and zh.ts, where main appended the
moved access, data, automation and datasource rows at the end of the table
next to this branch's six rows. Kept both sides, main's rows first.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jRu5CpdGJFtUFzPvUy
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · seat domain:ui#4 · session_01QsR2jTfvjNTaR29ehLk26T · 2026-10-27T21:42Z · head 1f38a0ab43956eb054a024173ef2fd0f4ee2898d

  1. Shape: draft, base main, Fixes #26836. Clause-②: no and Metadata-protocol: none are in the body and the changeset, and they match the claim. Five files, all inside the claimed surface: StudioDesignSurface.tsx (+151/−6, AccessPillar only), the studio namespace en.ts/zh.ts (+7 each), the new StudioDesignSurface.accessCreateTaken-26836.test.tsx, and one root changeset. No packages/spec/**, no server package, no metadata key.
  2. Premise, measured: the seat read the diff. doCreate reads client.list('permission') and client.listDrafts({ type: 'permission' }) across packages before any save (the Interfaces create's pair). A taken identifier is refused in the dialog with a sentence naming the set, and with an Open path when the set is on this package's rail. A fresh one saves as before, with the first-write pin kept for the race. An open set with a pending draft gets Discard draft, which confirms, then calls client.reset('permission', name, { state: 'draft', packageId }), the metadata editor's existing discard door. A published set re-reads its live body (publishNonce plus a local nonce), and a never-published one leaves the rail. The confirm sentence differs for the two cases, which is why it does not reuse engine.edit.discardDraftConfirmBody. The new file has 9 cases, and the ablation reddens 3.
  3. Gates, pinned to the head: check-runs?filter=latest 43 success, 9 skipped, 0 failing or pending · check-governed-merges --pr 26916: NOT governed, +491/−6. mergeable_state: clean. The head is a merge of origin/main 5ce3404a (no rebase), resolving the studio en.ts/zh.ts append conflict with both sides kept and no duplicate key. check:i18n-designer-parity exits 0, and the delta against main is still the same five files.
  4. Accepted deviations: the @object-ui/i18n patch bump follows from the six rows living there. The run is narrowed to 66 app-shell files, and CI's Test UI shards run the rest. Open question, answered A by the seat: the door's draft-mode first-write pin stays lifecycle-scoped, as SaveMetaItemRequestSchema.parentVersion and the metadata-protocol changelog declare. Each create surface checks published rows on the client. B would change a published contract for every caller, with no measured producer hitting the sub-second race, and it would need a contract review this lane does not take. No server child is owed.
  5. Out of scope: the thread's note that the Data, Automations and App creates use the same draft-only pin is filed as studio: the Automations and App creates can write a blank draft over a published flow or app of the same identifier (first-write pin is draft-only) #26917 (p2, domain:ui). No skip, quarantine or raised timeout. Skills-docs: is not owed.
  6. Prose face, checked against the diff: the code comments state the lifecycle-scoped pin and the race the client check leaves, and they cite Studio Access: creating a permission set whose derived id equals an already-published set puts a blank draft over the live set (publishing it would wipe its grants), and Studio offers no way to discard it #26836 and objectui#11773. The changeset's Before, Now and Discard bullets match the code. The six en and zh rows are paired.

Landing: no contract review is owed (no published schema, no governed text, Clause-②: no). The seat marks the PR ready and arms auto-merge. On MERGED, Fixes #26836 closes the card.

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/m tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants