Repository navigation
Commit 35eb2f0
feat(app-shell): the locked permission matrix offers Clone to customize through the published clone_permission_set action (objectui#5987) (#10754)
Fixes #5987
Clause-②: no
Placeholders in this body are written as capitalised words (NAME,
PR_NUMBER) rather than in angle brackets, because the body sanitizer
strips angle-bracket shapes at rest.
## What changed
`packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx`,
`i18n.ts` (new rows only, plus one rewritten hint), a small helper
`permission-set-clone-dispatch.ts` (resolves the published action off
the object definitions and shapes the runner dispatch), one new pin
file, one changeset (`@object-ui/app-shell: minor`; the dispatch order
said `patch`, the contract review at `48aaf0f068` flagged the conflict
with `.changeset/README.md`'s "minor (new feature)" and the `feat(`
headline as the seat's call, and the seat then ruled `minor`, correcting
its own order; sibling objectui#10449 is `minor`). The lock predicate
`isArtifactBackedLayer` is byte-identical to `main`; no second
predicate.
Why the helper (second commit): `permission-slice.authoredKeys.test.ts`
scans `PermissionMatrixEditor.tsx` for `{ ...spread, key }` literals
inside `setDraft(` regions and reads their keys as permission-set
facets. Its `setDraft(` regex also matches the prose `funnels through
setDraft (matrix checkboxes, …` in a `//` comment, and the `)` that
would close that region sits on the next comment line, which the walker
skips wholesale — so on `main` too that region runs to end of file and
every leading-spread literal below it is counted. My dispatch literal
(`{ ...localized, objectName, params }`, the action call's argument
shape, not a facet) landed inside it and CI read `objectName` / `params`
as dropped facets. Moving the dispatch construction into the helper,
which the scan does not read, is the honest fix: the keys were never
facets, and the slice's carried-key list is untouched. The scanner
defect itself is reported in the dev report, not changed here.
- The locked editor (artifact tier is the deciding gate:
`lockedByArtifactTier`) renders **Clone to customize** in the header
slot Save would occupy, and a visible guidance line (`role="note"`) that
names cloning first. The badge hint `perm.readOnly.artifact.hint` is
rewritten so cloning comes first and the former remedies (edit the
source artifact and redeploy, a new runtime set, `OS_METADATA_WRITABLE`)
follow as secondary routes; the server's reason and code stay in it.
- Clicking it runs the server-published `clone_permission_set` record
action on `sys_permission_set` through the console's shared action
runner (`useAction()`), resolved by name off the object definition the
console metadata store holds (`useMetadata().ensureType('object')` then
`findCloneAction`), with the set's `sys_permission_set` row (found by
`name`, the same lookup `AssignedUsersSection` uses) stashed under
`params._rowRecord` and the declared `params` surfaced as `actionParams`
(`buildCloneDispatch`) — the dispatch shape of `DeclaredActionsBar`. No
clone is assembled from create/update calls: an object that publishes no
such action, or a set with no row, is a named refusal
(`perm.clone.actionMissing`, `perm.clone.rowMissing`).
- On success the routed metadata admin navigates to `../NAME`
(`relative: 'path'`, the spelling `ResourceEditPage` uses after create),
where the clone loads with no code layer and is writable. An embedded
host has no route and gets a `role="status"` notice naming the clone.
- Item 3: a save that still reaches the environment door and is refused
`403` with code `NOT_OVERRIDABLE` (matched with `errorCodeIs`, never on
prose) renders `perm.save.locked` with the same Clone button and keeps
the server's sentence underneath, instead of the transport's message
alone. Env door only (`!packageId`).
## H1 — the agreement measurement
**Booted showcase: NOT MEASURED.** Blocker, measured in this container:
`/home/user/objectstack` has `node_modules` with 0 entries and no `dist`
under `packages/spec`, `packages/plugins/plugin-security` or
`packages/cli`; objectui's `node_modules/@objectstack` holds only `spec`
(17.4.0), no runtime plugin. A showcase boot needs a full objectstack
install plus a workspace build under the shared verify lock, which is
outside this card's objectui-only file surface and the foreground cap.
Not guessed.
**Source-level reading instead** (objectstack `origin/main` at
`2bbebf5321`; `sys-permission-set.object.ts` is byte-identical at tag
`@objectstack/plugin-security@17.4.0`, `git diff` empty), on the same
two inputs:
- Server `isArtifactBacked(type, name)` in
`metadata-protocol/src/protocol.ts` is true when
`lookupArtifactItem(type, name)` answers — artifact items only, the
`sys_metadata` rehydration sentinel excluded, plain-key shadows ignored
— or when `isNestedArtifactField` does (declared `field`-only,
irrelevant to `permission`).
- The layered read's `code` layer is the MetadataService copy when one
exists, else `lookupArtifactItem(...) ?? registry.getItem(...)`; a
`sys_metadata` row reaches that `getItem` fallback as
`hydrateOverlayIntoRegistry` registered it, `_provenance: 'org'` stamped
by `stateTenantAuthorship` and `_packageId` absent
(`stripDerivedProvenance` strips it at write) or, on the listing path,
re-stamped by `getMetaItems` from the row's `package_id` column, where
the `sys_metadata` sentinel appears only on a legacy package-less row.
Its `provenance` is the item's own `_provenance` stamp
(`resolveLockState` → `extractProtection`, `@objectstack/spec` kernel
`metadata-protection.zod.ts`).
- Client `isArtifactBackedLayer(lay)` is `code` non-null AND
`code._packageId !== 'sys_metadata'` AND `provenance !== 'org'`.
- Case A, a code-package set: server true → `saveMetaItem` refuses the
env-door PUT with `403 NOT_OVERRIDABLE` (`allowOrgOverride: false`, when
`environmentId` is defined); client `code` carries the package id →
locked. Agree.
- Case B, a `sys_metadata`-backed package set (`managed_by: 'package'`,
definition only in `sys_metadata`): server `lookupArtifactItem`
(`getArtifactItem`, artifact-only) misses a DB-hydrated row → false →
the `allowRuntimeCreate` tier accepts; client `code` is the hydrated
item, registered with no `_packageId` and stamped `_provenance: 'org'`
(`hydrateOverlayIntoRegistry` then `stateTenantAuthorship`), so
`provenance !== 'org'` is false and the editor does not lock. Agree.
- The data door's `classifyPackagedPermissionSet`
(`packaged-permission-set-lock.ts`) decides from the same artifact rule
(`declaredPackageIdOf` excludes `_envProjection` echoes and the
sentinel), so all three spellings key on one fact.
- Known residue, unchanged (objectui#4526): on a single-kernel host
(`environmentId` undefined) the server disengages its artifact tier and
accepts; the client still locks. Conservative, previously accepted.
## H2 — how the console invokes a published record action
`clone_permission_set` is `type: 'api'`, `method: 'POST'`, `target:
'/api/v1/data/sys_permission_set'`, `bodyExtra: { active: true }`,
`params`: `label` and `name` (required text), `description` and the five
facets with `defaultFromRow` / `carryOver`. It is not a `script` action,
so `serverActionHandler` is not its path: the console dispatches `type:
'api'` through `useConsoleActionRuntime.apiHandler`, registered on the
`ActionProvider` that `GlobalActionRuntimeProvider` (ConsoleShell) wraps
every route in, with `onParamCollection` seeding `defaultFromRow` params
from `params._rowRecord`. The editor uses that same provider via
`useAction()`.
What the action returns: **NOT MEASURED** on a live server. By source,
the data door answers the spec's `CreateDataResponse` — `{ object, id,
record }` — with status 201 (`runtime/src/domains/data.ts`,
`CreateDataResponseSchema` in the installed `@objectstack/spec@17.4.0`),
and `apiHandler` unwraps the `{ success, data }` envelope to
`result.data`. The editor reads `result.data.record.name`; when that is
absent it says so (`perm.clone.noName`) rather than guessing.
## H3 — strings
All through `metadata-admin/i18n.ts`, en-US and zh-CN rows. Rewritten:
`perm.readOnly.artifact.hint`. New: `perm.clone.action`,
`perm.clone.hint`, `perm.clone.guidance`, `perm.clone.actionMissing`,
`perm.clone.rowMissing`, `perm.clone.noName`, `perm.clone.done`,
`perm.save.locked`. `check:i18n-designer-parity` and `check:i18n-drift`
(designer population) both green.
## H4 — item 3
Measured with the 403 mocked at the data-source boundary (`client.save`
rejecting with `status: 403, code: 'NOT_OVERRIDABLE'`). Before: the red
strip rendered the transport's message. For the metadata door that
message (`saveMetaItem`'s artifact-tier refusal) says to edit the source
artifact and redeploy or set `OS_METADATA_WRITABLE` — the pre-ruling
remedies, never the clone — so it was the generic form. Changed as
described above; the pin and its controls are below.
## Pins and ablations
New file `PermissionMatrixEditor.cloneToCustomize.test.tsx`, 17 tests,
real `ActionProvider` with the runtime's three seams faked (api handler,
param dialog, toast); adapter and metadata store faked through
`@object-ui/react` with `importOriginal` spread.
Red on the base editor plus base i18n (`a2f361b8c1`, the test file
kept): `Tests 11 failed | 6 passed (17)` — the 6 that pass on base are
the controls (org-owned / sentinel / type-locked / host-locked show no
clone prompt; other refusals stay generic), which hold on base by
construction. Head: `17 passed`.
Per-hunk ablations, each landed on disk (anchor 1 → 0, replacement
present), run, and restored with `git checkout HEAD -- ABSOLUTE_PATH`,
proven by `git diff HEAD` empty and blob hash equal to HEAD's:
- A header Clone button removed → 8 failed (primary action, zh,
dispatch, opens writable, embedded notice, cancel, both no-fallback
refusals).
- B item-3 branch removed → 2 failed (NOT_OVERRIDABLE guidance,
lowercase-code spelling).
- C en hint with the secondary routes moved before cloning → 1 failed
(ordering pin).
- D visible guidance strip removed → 2 failed (primary-action pin, zh).
- E zh `perm.clone.action` row deleted → 1 failed (zh), and
`check-i18n-designer-table-parity` exit 1 naming `perm.clone.action`.
- F declared params not surfaced to the runner → 2 failed (dispatch pin,
cancel pin).
- G clone not opened after success → 1 failed (opens-writable pin).
## Gates (local; the farm is CI's)
All under the shared verify lock unless noted; verdict lines quoted from
the tools.
- `pnpm exec vitest run --maxWorkers=2` over `PermissionMatrixEditor*`,
the metadata-admin i18n pins and
`PackageOwdOverviewPanel.readDecorationStrip`: `Test Files 22 passed
(22) · Tests 119 passed (119)` after the one selector fix (first run
118/119; the zh pin matched two elements).
- Whole `packages/app-shell/src/views/metadata-admin/` directory, every
test file, at `48aaf0f068`: `Test Files 314 passed (314) · Tests 3629
passed | 1 skipped (3630)` (`VERDICT command-exit 0`).
- `pnpm exec turbo run build --filter=@object-ui/app-shell^...
--concurrency=2`: `Tasks: 28 successful, 28 total`.
- `pnpm --filter @object-ui/app-shell type-check` (script name echoed):
`VERDICT command-exit 0`, no `error TS`.
- eslint on the editor and the helper (lock-free, `--format json`,
re-measured at `2f47d40422`): `0 errors`; editor 21 warnings, 20
pre-existing on unchanged lines plus one `no-explicit-any` on the new
clone handler's `catch (err: any)`, the spelling `doSave` already uses;
helper 0. Population = `eslint .` from `packages/app-shell` under the
root flat config, which sets no `parserOptions.project` /
`projectService`, so type-aware linting is off and this diff cannot move
any untouched file's verdict. `pnpm lint` (repo scan) is CI's.
- Lock-free gates, each exit 0: `check-i18n-designer-table-parity`,
`check-i18n-en-drift`, `check-i18n-call-site-keys`,
`check-changeset-presence` (`4 source file(s) of 1 released package(s)
changed ... declares 1 changeset(s)`), `check-changeset-no-major`,
`check-changeset-fixed`, `check-changeset-overwrite`,
`check-changeset-claims`, `check-pending-changeset-literals`,
`check-control-bytes`, `check-new-cross-file-line-citations` (`0 new
citation(s)`), `check-vi-mock-specifiers`, `check-vi-mock-inherit`,
`check-vi-mock-override-shape`, `check-test-path-roots`,
`check-unreferenced-sources`, `check-handler-key-read-sites`,
`check-object-metadata-write-doors`.
- Not run locally, CI's: `pnpm lint`, the four/eight test shards, `pnpm
check` (needs the CLI build).
## Serial
`origin/main` fetched into a private ref before each push. At the final
push (`2f47d40422`) `origin/main` is `704e05b09d`: 6 commits since the
base `a2f361b8c1` by `git rev-list --count` (no merges; the review
record counted 7 — the six are listed in the dev report), none touching
the changed paths; `git merge-tree --write-tree` against HEAD exits 0.
No merge commit needed.
## Round 2 (published text only)
Head `2f47d40422`, commit `docs(changeset): grade Clone to customize
minor and name the door whose refusal lacks the clone remedy
(objectui#5987)`, one file:
`.changeset/5987-permset-clone-to-customize.md` (md5
`796364a5879f561ed0748f29b4eee335` before,
`31bb4262ee708eab72e78455936b16a9` after). No source change, so the
pins, ablations, type-check and directory run above hold as measured at
`48aaf0f068`.
- Frontmatter `'@object-ui/app-shell': patch` → `minor` (the seat's
ruling after the review; see "What changed").
- The CHANGELOG-bound sentence now reads: "was never on the screen, and
the metadata-door refusal a Studio save receives names only the
pre-ruling remedies; the data door's refusal is the one that names the
Clone action." It used to say the server's 403 refusal names the clone,
which is true of the data door only.
- Gates at this head, each exit 0: `check-changeset-fixed`,
`check-changeset-no-major` (together `changeset:check`),
`check-changeset-presence` (`4 source file(s) of 1 released package(s)
... declares 1 changeset(s)`), `check-changeset-overwrite` (`No
pre-existing changeset was modified or deleted` — this changeset is new
on this branch), `check-changeset-claims`, `check-control-bytes`,
`check-new-cross-file-line-citations` (`0 new citation(s)`); eslint and
Serial as re-measured above.
- The H1 Case B sentence's server half was aligned to the record's
reading (`getArtifactItem` misses a DB-hydrated row) alongside the
verbatim client-half replacement.
## Acceptance notes
- Observation (objectstack side, not in this PR): the metadata door's
artifact-tier refusal text in `saveMetaItem` still names
edit-and-redeploy and `OS_METADATA_WRITABLE`, while the data door's
`PackagedPermissionSetLockedError` names the Clone action. The Studio
editor hits the metadata door, which is why item 3 had to key on the
code. Reported in the dev report for the seat, not filed here.
- The 6 control pins pass on base by construction (they assert an
absence); the 11 affordance pins are the red-then-green evidence.
- `useAction()` falls back to a bare `ActionRunner` when no
`ActionProvider` is mounted (a host that embeds the editor outside
`ConsoleShell`); that fallback is the platform's existing shape for
every `useAction()` consumer and is not changed here.
- If the typed clone `name` collides with a package-declared set, the
data door's insert leg refuses it; that refusal is the published
action's own and reaches the user through the runner's error toast,
unchanged.
- The single-kernel residue of objectui#4526 is unchanged.
---
_Generated by [Claude
Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 63835d9 commit 35eb2f0
5 files changed
Lines changed: 916 additions & 4 deletions
File tree
- .changeset
- packages/app-shell/src/views/metadata-admin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
0 commit comments