Repository navigation
feat(metadata-protocol): an unmanaged install copies no protection lock, and names each one it drops - #23075
Conversation
…otection lock and names each one ADR-0131 D6 makes an unmanaged copy the environment's own metadata, editable like anything authored there; an item the package shipped with an ADR-0010 `_lock` was copied with it and refused the next save `403 ITEM_LOCKED`. The ruling on the D6 lock fork (record 6119681934, letter A) settles it: the copy carries no `_lock` / `_lockReason` / `_lockSource`, and the import's answer lists each lock it dropped with the package's reason. - spec: `ImportUnmanagedPackageResponseSchema` gains `strippedLocks`. - metadata-protocol: `stripUnmanagedImportLock` in the pure half; the plan carries the list in write order and the verb returns it. - cloud-connection: the install door relays it; cli prints one line per lock. - dogfood: the unmanaged suite locks two CRM items and pins the strip at the Studio door, with a managed-install control. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…he CLI and the Studio door Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
… of 15213-install-modes Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
… the authorable surface for strippedLocks Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…wer, the rows, the save door, the delete Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 147 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85f41ef4183bc50018fb8ed525b5e5f2c7f4d516 && git checkout 85f41ef4183bc50018fb8ed525b5e5f2c7f4d516
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 038d4c958837fb42e71842c18f062f37bd866811 58984f5c5baf2eb298c8346f1a05ddf8e8d885a6 && git checkout -B drift-repro 038d4c958837fb42e71842c18f062f37bd866811 && git merge --no-ff 58984f5c5baf2eb298c8346f1a05ddf8e8d885a6
node scripts/docs-audit/affected-docs.mjs --json 038d4c958837fb42e71842c18f062f37bd866811
|
…er spelling — the protection block and _lockDocsUrl too The seat's decisions on round 1's two open questions, as the application of the D6 lock-fork ruling (record 6119681934): on a type whose schema declares it, the copy drops the author-facing `protection` block and reports its lock and reason; the `_lock` envelope's fourth key, `_lockDocsUrl`, goes with the other three. Which types declare `protection` is asked of the save door's own schema resolver (`overlaySchemaDeclaredKey`); on any other type the door refuses the block exactly as before. The lock reported is the one a managed install binds, computed by the loader's own `applyProtection` on a scratch copy, so an item carrying both spellings is reported once, with the block's lock and reason. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…riptions Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
…here the loader's translation puts it Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Rendered 2026-10-12T21:06Z by the isolated at-tier reviewer subagent of ① Derived judgmentsThe published surface this diff implies, judged line by line against the diff:
Check-runs on this head (the gate verdicts): 48 check runs, 41 ② Semver level
③ Boundary flagsRound 1 report
Round 1 open questions (both): decided by the seat in Round 1 out-of-scope finding (class c, the Round 2 report
Round 2 open questions: none. Round 2 out-of-scope findings:
No flag is escalated: neither seat decision goes beyond ruling A, no refusal moved, and the one defect found is carried by its own issue. Implemented-by: VERDICT: PASS |
Step 4 of os-regen-merge: the two os-regen mdx files regenerated from the merge of origin/main 55828c5, carrying #23098's and #23075's rows and this card's member rows. Claude-Session: https://claude.ai/code/session_01GZSPSQ5ArPjSJdxxuqwGoZ Co-authored-by: Claude <noreply@anthropic.com>
…s a package permits, and reads the door's answer (objectui#12288) (objectstack-ai#12317) Fixes objectstack-ai#12288 Clause-②: yes The marketplace package page offers the install modes a package permits (managed, or an unmanaged copy), sends `installMode` on the install, lists the locks an unmanaged copy dropped, and shows each install-mode refusal's own message. This is the objectui stage S4 of objectstack-ai/objectstack#15213 (ADR-0131 D6). The console has no mode rule of its own: what is permitted is the manifest's `installModes`, and what happened is the door's answer. ## What the page reads, measured on objectstack `main` `e540e7ffb` - **The `installModes` key is read from the manifest the install door judges.** The PM's hypothesis that it reaches the page "through a payload the page already reads" is false. The page read two payloads: the catalog detail (`GET /api/v1/marketplace/packages/:id`, the control plane's `sys_package` row, passed through verbatim by `MarketplaceProxyPlugin`) and the install-local listing. Neither carries the key in anything this session can measure. The cloud repo is not attached here, and egress to the live catalog is refused by the container proxy, so the detail's projection stays unmeasured. The door itself fetches `GET /api/v1/marketplace/packages/:id/versions/VERSION/manifest` and judges `installModes` on that payload's `data.manifest` (`judgeInstallMode` in `marketplace-install-local-plugin.ts`). The browse proxy forwards every GET under `/api/v1/marketplace/` except `install-local`. So the page now reads that same path, at `latest`, the version install-local fetches when it is given no `versionId`, which is how the page installs. The key is read where the door reads it (`declaredPackageManifest`): on a compiled artifact's nested `manifest:`, else at the top of a flat body. - **Only install-local takes a mode.** `POST /api/v1/cloud-connection/install` takes `package_id` and `seed_sample_data` and forwards nothing else. So the read and the choice exist only where `features.installLocal` makes install-local the page's door. - **A package permitting only `unmanaged`** is refused by the door when no mode is sent (the door's own test: "refuses `managed` for a copy-only package — 422, naming `unmanaged`"). The page sends the one mode a manifest declares. That follows from the manifest and the door, not from a console rule. Such a package draws no choice, but the page says before the click that the install is a copy. - **`strippedLocks`** is `{ type, name, lock, reason? }` (`ImportUnmanagedPackageResponseSchema`, objectstack-ai/objectstack#23075), with `lock` one of the spec's `MetadataLock` values except `none`. The unmanaged install and the lock strip ship in the same pending v18 changesets (`15213-install-modes.md`, `15213-unmanaged-lock-strip.md`). No released runtime answers `unmanaged` without the list, so it is typed required and read with no fallback. ## What changed - `marketplaceApi.ts`: `getDeclaredInstallModes(packageId, versionId = 'latest')` reads the key as above. It returns `undefined` for an absent key and for a declaration the spec refuses. The door refuses such a package whatever mode is sent, so the page shows the door's message. Absence is never resolved to a default here. A mode named twice counts once. `installLocal` takes an optional `installMode` and sends it only when named. `LocalInstallResult` is now the managed or the unmanaged answer. `LocalInstallMode` and `LocalInstallStrippedLock` are the console's reading of the install door's `installMode` and `strippedLocks`; the `@objectstack/spec` 17.7.0 objectui resolves declares neither (only `lock` is typed from the spec's `MetadataLock`). `isPlainRecord` is imported from `@objectstack/spec/data`. - `MarketplacePackagePage.tsx`: - The choice is a radio group under the description, drawn only when the manifest permits more than one mode. It starts unpicked, because the spec ranks no permitted mode above another (the same shape as `PackagesPage`'s delete-mode choice). The install button waits for a pick. While it waits, the disabled Install says why (`marketplace.install.mode.required`): a focusable wrapper opens the reason on hover and focus, and it is the button's accessible description. - No `installModes`, or no readable manifest: no choice and no `installMode` sent, so the door's default stands. One declared mode: no choice, and that mode is sent. - An `unmanaged` answer gets its own sentence naming the base, and its `strippedLocks` list shows each item, its lock and the package's reason verbatim. It mounts no install-time suggestions, since nothing is registered under the package's id. Before this, the answer carried no `hotLoaded` and would have read as "loads when the runtime next restarts". - The three refusals (`INSTALL_MODE_NOT_PERMITTED`, `UNMANAGED_INSTALL_WALLED_POSTURE`, `RESOURCE_CONFLICT`) fall through to the existing branch that shows the envelope's `message`. No console sentence replaces them. - A copy that was asked for and answered without `installMode: 'unmanaged'` comes from a runtime that predates the key and installed the package managed. The page says so, the reading `os package install --install-mode` makes. - `packages/i18n/src/locales/*.ts`, all ten packs: nine keys under `marketplace.install` (`mode.label`, `mode.required`, `mode.managed`, `mode.managedHint`, `mode.unmanaged`, `mode.unmanagedHint`, `unmanagedSuccess`, `unmanagedStrippedLocks`, `unmanagedIgnored`). - `.changeset/12288-marketplace-install-mode.md`: `@object-ui/app-shell` and `@object-ui/i18n`, `minor`. **Clause-② yes:** the published `@object-ui/i18n` packs gain nine keys. `installLocal`, `getDeclaredInstallModes` and the new types live in `marketplaceApi.ts`, which is not exported from the `@object-ui/app-shell` entry. The exported `MarketplacePackagePage` keeps its props. ## Verification All at `d5441f2` unless a line says otherwise. Patch round 1 (head `6618b25`: `isPlainRecord` imported from the spec for `check:spec-symbols`, and the disabled Install's reason) has its readings in the os-dev report on objectui#12288. Every heavy run went through `os-verify-lock.sh`; each reading is that command's own `VERDICT` or summary line. - **Dependency closure:** `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` gave `VERDICT command-exit 0`. - **The two touched dirs, at `d5441f2`:** `pnpm exec vitest run --maxWorkers=2 packages/i18n/ packages/app-shell/src/console/marketplace/` passed 115 test files (1534 tests passed, 13 skipped), exit 0. - **The two touched packages in full, at `50ec625`:** `pnpm exec vitest run --maxWorkers=2 packages/app-shell/ packages/i18n/` gave 1398 files passed, 1 failed, 1 skipped (14023 tests passed, 2 failed). - The failure was `de-quote-pairing-3876.test.ts`. It counts the German pack's matched quote pairs, and the two new `„{{name}}“` spans move it from 85 to 87. The count is updated in `adf0131`, as each earlier PR adding such a span did, and that file is green in the run above. - Since `50ec625`, app-shell's source changed only by `installLocal`'s overload signatures. esbuild's output for `marketplaceApi.ts` is identical at `50ec625` and `d5441f2` (control: BASE's output differs), so the full-suite reading stands for HEAD's runtime code. - **`pnpm --filter @object-ui/app-shell type-check`** (echoes `tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. - It was exit 2 at `adf0131`. The union return type hid `hotLoaded` from the catalog page's org-package install in `MarketplacePage.tsx`, which sends no mode. - The fix is in `marketplaceApi.ts`: an overload types a request that names no mode with the managed answer, the door's own rule. `MarketplacePage.tsx` is not edited. - `tsc -p tsconfig.test.json --listFiles` lists the new test file (control: a sibling test file is listed too). - **`pnpm --filter @object-ui/i18n type-check`:** exit 0. - **Lock-free gates,** each exit 0: - `pnpm check:i18n-keys`; - `pnpm check:i18n-drift`: 8 keys added, 0 `en` values changed; - `pnpm check:i18n-dead-keys`: none of the eight keys is a candidate; - `pnpm check:control-bytes`; - `pnpm check:new-line-citations`: 0 new; - `node scripts/check-changeset-presence.mjs`. - **ESLint, a declared narrowing to the 14 changed `.ts` / `.tsx` files:** 0 errors and 47 warnings. That is the same per-file count as BASE for the two edited source files (11 and 36), and 0 for the rest. - Population: the `**/*.{ts,tsx}` block of `eslint.config.js`. File count: read from `--format json`. - Invariance: the config is not type-aware (`tseslint.configs.recommended`, no `parserOptions.project` or `projectService`), and no rule under `eslint-rules/` reads the filesystem. So this diff cannot move the verdict of a file it does not touch. Repo-wide `pnpm lint` is CI's. **Reverse verification, at `50ec625`.** Each ablation mutated through an anchor that had to hit (objectstack's `ablation-replace.mjs`, wrap mode), ran `MarketplacePackagePage.installMode-12288.test.tsx` (17 tests), and restored. Each restore was proven by a blob equal to HEAD and an empty `git diff HEAD`. The test imports the page and `marketplaceApi.ts` from `src`, so no build sat between a mutation and its run. The expected directions were declared before the run, and each was observed: - **A1, the page sends no `installMode`:** 6 red (the five sent-mode pins and the ignored-copy pin). - **A2, the choice is never offered:** 9 red (both-modes ×2, picked ×2, the managed control, the three refusals, the ignored copy). - **A3, the unmanaged answer is not read:** 2 red (the stripped-locks pin and its no-lock control). - **A4, the nested `manifest:` is not read:** 1 red (the compiled-artifact case). ## Acceptance notes - **No docs page carries this flow.** No objectui guide describes the package page's install flow (`content/docs/guide/console.md` names the marketplace route only), so AGENTS.md objectstack-ai#2 has no page to update. The authoring page for install modes is objectstack's `content/docs/deployment/install-modes.mdx`. Carrier: none. - **An unmanaged copy is not visible on the page afterwards.** The install-local listing lists only ledgered installs, and a copy writes no ledger entry under the package's id. After a copy the page still offers Install, and a managed pick is then refused `RESOURCE_CONFLICT`, whose message names the base. No served read tells the page a copy exists. Carrier: none. - **`seeded.mode: 'skipped'` is not shown.** The unmanaged answer says so when the package declares sample data; the page does not repeat it. Carrier: none. - **The local mirror types** (`LocalInstallMode`, `LocalInstallStrippedLock`) are for replacement by `InstallModeSchema` / `ImportUnmanagedPackageResponse` once objectui resolves an `@objectstack/spec` that exports them. Carrier: the PR that moves objectui to `@objectstack` 18. - **A stale backend pointer.** The local-install block header in `marketplaceApi.ts` points at the install door's former framework path; the door lives in objectstack `packages/cloud-connection/src/`. Not edited here. Carrier: none. Built for objectui#12288 in session `https://claude.ai/code/session_01RZYixBnXPWvcm2YenWo6eJ` (`claude/issue-12288-marketplace-install-mode`). --- _Generated by [Claude Code](https://claude.ai/code/session_01RZYixBnXPWvcm2YenWo6eJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #15213. This PR carries the
_lockstrip that ruling A on #22971 decided, with round 2's two seat decisions (comment 6128999134 on #15213): theprotectionblock is stripped too, and so is_lockDocsUrl. S3 (the authoring docs page) and S4 (the objectui marketplace choice) stay on the card, so #15213 remains open.Clause-②: yes (widening: a copied item no longer carries the vendor's lock, and the import result gains the stripped-lock list; the dev measures the exact published shape)
The measured published shape is one new response field:
strippedLocksonImportUnmanagedPackageResponseSchema(@objectstack/spec/api). The install-local answer forinstallMode: 'unmanaged'relays it. Round 2 did not move that shape; only the field descriptions changed. No other published contract changes.What changes
An
unmanagedinstall (ADR-0131 D6) copies a package into the environment as the environment's own metadata. A package can lock an item in two ADR-0010 spellings: the_lockenvelope (_lock,_lockReason,_lockSource,_lockDocsUrl), or the author-facingprotection: { lock, reason, docsUrl }block that a managed install translates into the envelope. Until now, an item locked with_lockkept it on the copy, so the next Studio save of that copy answered403 ITEM_LOCKED. An item locked withprotectionkept an inert block that bound nothing, so the vendor's lock vanished without being named. Ruling A (record 6119681934, closed by 6119699999) decides this: the copy keeps no lock, and the import's answer names each lock it dropped, with the vendor's reason.@objectstack/metadata-protocol:stripUnmanagedImportLock, in the pure half (package-unmanaged-install.ts), removes the four envelope keys from every planned item. On a type whose schema declares one, it also removes theprotectionblock, andprotection.docsUrlgoes with it.protectionis asked of the save door's own schema resolver, throughoverlaySchemaDeclaredKeyinprotocol.ts. No list of types is kept anywhere.strippedLocksin write order, andimportUnmanagedPackagereturns it.no-overlay,no-deleteorfull):{ type, name, lock, reason? }, with the reason verbatim.applyProtection, run on a scratch copy of the lock keys. That translation lets the block win, so an item carrying both spellings is reported once, with the block'slockandreason. This was measured: on a real managed install,_lock: 'full'plusprotection: { lock: 'no-delete' }servesno-deletewith the block's reason, and an overlayPUTanswers 200.'none'declares no lock, so nothing is reported for it.422 INVALID_METADATA, rolled back). This covers an unknown_lockvalue, a reason or docs link that is not a string, and a malformedprotectionblock.protectionblock on a type whose schema does not declare one (action,hook,page) is left in place, and the save door refuses it exactly as today.@objectstack/spec:ImportUnmanagedPackageResponseSchema.strippedLocksis the one declaration. The protocol's entry type is derived from it by indexed access and is never restated.lockisMetadataLockSchemawithout'none'.@objectstack/cloud-connection: the install door relaysstrippedLocksexactly as the import states it.@objectstack/cli:os package install --install-mode unmanagedprints one line per dropped lock underLocks removed, asTYPE.NAME: LOCK — REASON. This is the ruling's spelling; when the package gave no reason, the dash and reason are left off.INSTALL_MODESTSDoc, the pure module's header, theimportUnmanagedPackageTSDoc and the door docblock. The merged pending changeset15213-install-modesis NOT edited. The new changeset15213-unmanaged-lock-stripstates that it supersedes that changeset's two_locksentences.Shape chosen:
{ type, name, lock, reason? }, the smallest structure that renders the ruling's line. The array is always present and empty when nothing was locked.Measurements
These were taken on base
0cbfb7c32unless a later head is named.git grep '#22971'hit exactly the five named texts. After this PR it hits only the merged changeset.metadataDefaults.locknever reaches the import. No schema declaresmetadataDefaults(ADR-0010 §3.2 / Phase 3 is not implemented), and a body that carries it is refused whole with422 UNMANAGED_INSTALL_UNCOPYABLE. That refusal is pinned.protectionblock. It arrives as written, because only the managed loader translates it. Before this PR, the copy kept it verbatim and it bound nothing. Round 2 strips it on declaring types._lock: 'full'refusedPUT /api/v1/meta/dashboard/pipeline_dashboardwith403 ITEM_LOCKED.ImportUnmanagedPackageResponseSchemais the declaration; the door builds its answer from the import's result, and the CLI reads that answer._lockDocsUrl: before round 2 a copy kept it in its row, where it was served nowhere and bound nothing. It is now stripped with the other three keys, with no new report field.Verification record (head
58984f5c5)Dogfood:
install-local-unmanaged-mode.dogfood.test.ts, the real Studio door. The fixture locks three CRM items before the build:_lock: 'full', a reason and_lockDocsUrl;_lock: 'no-delete';protection: { lock: 'no-overlay', reason, docsUrl }.Results:
dist, round 1's fixture had 4 of 20 failing, including403 ITEM_LOCKEDon the dashboardPUT. Theprotectionhalf's red was measured by the ablation below.full, and the dataset carries the_lockits block was translated into, with the block's reason.Unit:
package-unmanaged-install.test.tspasses 62 of 62.Ablations, one per decision. Each one: committed state,
node scripts/ablation-replace.mjs, then a restore proven by the blob equalling HEAD andgit diff HEADbeing empty.view/crm_lead_open is locked (_lock=full, source=overlay)and the delete pinprotectionstrip offundefined(blob53bae6d2342fto18c22045dbc1).metadata-protocolwas rebuilt, andablation-dist-preflightfound the marker indist.--absentexit 0.objectdeclaration for every type (blob64c7eb5e87bfto9fba99182174)pagepin went red: the import accepted the page where the door refuses it with 422_lockDocsUrlnot stripped64c7eb5e87bfto07379596f524)Affected closure (
turbo ls --affectedagainst the merge base84c4fffbc; 76 packages, 71 with atestscript):pnpm turbo run test, in 6 batches throughos-verify-lock.sh. All 339 turbo tasks succeeded (the count includes the^builddependencies).@objectstack/cliran itsunitproject: 283 files, 4272 tests. Itsintegrationlayer is left to CI: the role file owes it locally only when a spawn entry is touched, and none is.@objectstack/dogfoodran its fullpnpm test: 258 files passed (1 skipped), 2121 tests passed (9 skipped).metadata-protocol,dogfood,spec,cloud-connectionandcli.--no-inline-config --format json, gave 0 errors, 0 warnings and none ignored. The config enables no type-aware linting.check:generated: the reference docs moved with round 2's descriptions and were regenerated withgen:docs. On the rerun, all 14 artifacts were up to date.Gates:
dispatch-gates --commands --repo objectstack-ai/objectstackat58984f5c5.pnpm check:type-check-debtis NOT MEASURED, skipped by the dispatch order.--ranreconciliation accounted for all 117, with 0 UNRUN.58984f5c5is green: all 12 workflow runs succeeded, the opt-in pack smoke was skipped, and every required context passed.Size: 804 changed lines (+771 / −33, 14 files, generated files included).
Acceptance notes
protectionblock. Measured at round 2's head on a showcase boot:PUT /api/v1/meta/app/probe_locked_appandPUT /api/v1/meta/dashboard/probe_locked_dash, each with a hand-authoredprotection: { lock: 'full', reason }, both answer 200.GETreadslock: noneandeditable: true.PUTand aDELETEboth answer 200.protectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096.stripUnmanagedImportLockandoverlaySchemaDeclaredKeyare exported from their modules for the tests only. The package index exports neither, so neither is published.GET /metaanswer carries no top-level lock envelope; the lock rides the served item's_lock. Seen while pinning and not investigated. Carrier: none. Recorded here by the seat, per its ACCEPT on feat(spec,objectql,cli): the template install mode — a package copied once into the environment ledger, fully editable, refused on shared-database multi-tenant postures (ADR-0131 D6) #15213.origin/main, and none of those commits touches a file in this diff.Generated by Claude Code