Skip to content

Commit e02833c

Browse files
feat(metadata-protocol)!: managed content is sealed — OS_METADATA_WRITABLE no longer opens an item a managed package ships (ADR-0131 D6, #15206 S2) (#22401)
Refs #15206 (S2) Clause-②: no **Stage S2 of #15206: managed content is sealed (ADR-0131 D6, regime C).** The operator hatch `OS_METADATA_WRITABLE`, and its legacy spelling `OBJECTSTACK_METADATA_WRITABLE`, no longer opens an overlay write onto an item a managed package ships, and no longer opens a removal of one. Such a request now answers `403 NOT_OVERRIDABLE`, and the refusal's first sentence names "a managed package". These stay as they were: - disabling a managed flow or action (operator-gated, under its wall); - cloning a flow under a new name, which records no linkage; - creating a new flow in Studio (the positive control). Declared test change outside the card surface: `packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts` (declared to `domain:services` on #6021). Its two hatch-OPEN cases now pin that the protocol's package door answers, not the lock; see Patch round 1. ## The mechanism (M3: one predicate) `isSealedManagedItem(type, name)` holds when the item is artifact-backed and its type's registry entry opens no overlay channel. The registry half is `registryAllowsOverlay(type)`, which is `isOverlayAllowed` without the environment variable. These all read this one predicate: - the save door (`refusePackagedBaseOverride`); - the removal door (`refusePackagedBaseRemoval`); - the read envelope (`packagedBaseRefusal`, which sets `editable` / `deletable`). The `/automation` definition doors ask the same protocol doors. The repository (`SysMetadataRepository.assertAllowed`) applies the same rule one layer down. With the hatch open, only `intent: 'runtime-only'` (a new item no package ships) passes. An `override-artifact` intent gets the sealed sentence; that covers draft promotion, restore and revert. For a shipped item, two other places no longer consult the hatch: the code-only check in `saveMetaItem`, and the repository route in `deleteMetaItem`. The sentence comes from one builder, `managedItemSealedSentence` in `packaged-base-regime.ts`. It is internal and not reachable from the package entry. - A type with an ADR-0126 regime row (`flow`, `action`, `permission`) keeps naming its sanctioned route: the clone or the switch. - Every other type says it is sealed, says that the hatch does not open a managed item, and keeps the source remedy. `SysMetadataRepository.readOnlyBaseOverrideError` drops its third parameter, `hatchOpen`, because nothing it chose survives the seal. **Built entry declarations (`dist/index.d.ts`), measured.** Nothing widens: - two new private members: `private static registryAllowsOverlay;` and `private isSealedManagedItem;`; - `static readOnlyBaseOverrideError(type: string, packageId: string): Error` loses its optional third parameter, which narrows it; no caller outside the package exists (`git grep`); - `managedItemSealedSentence` is not exported. ## Door table (M1, M2, M4, M5), measured on a real boot Setup: - The CRM example, booted through `bootStack(crmStack, { automation: true })`. - Base is `8311650228`. Head is this branch. - Two transports: REST is the `RestServer` `/api/v1/meta` routes; DISP is the runtime `HttpDispatcher`. - Hatch modes: - OS: `OS_METADATA_WRITABLE=flow,object,field,permission,position`. - LEGACY: the same value under `OBJECTSTACK_METADATA_WRITABLE`. - NONE: no hatch. - JOB: `OS_METADATA_WRITABLE=job`. The two transports read the same unless a row notes otherwise. The dispatcher serves no `DELETE` on `/meta` items: it answers `405 METHOD_NOT_ALLOWED` in every mode, at base and at head. The DELETE rows are therefore REST. | Probe | OS base | OS head | LEGACY base | LEGACY head | NONE and JOB (base = head) | |:--|:--|:--|:--|:--|:--| | PUT `/meta/flow/crm_convert_lead_wizard` (shipped) | 200 | **403** NOT_OVERRIDABLE | 403 | 403 | 403 | | PUT `/meta/object/crm_lead` (field relabel) | 200 | **403** | 403 | 403 | 403 | | PUT `/meta/permission/crm_sales_user` | 403 (plugin-security lock) | 403 (protocol door) | 403 | 403 | 403 | | PUT `/meta/position/sales_rep` (M5) | 200 | **403** | 403 | 403 | 403 | | PUT `/automation/crm_convert_lead_wizard` | 200 | **403** | 403 | 403 | 403 | | DELETE `/meta/flow` over a legacy overlay row | 200 | **403** | 403 | 403 | 403 | | DELETE `/meta/flow` with no row | 200 | **403** | 403 | 403 | 403 | | DELETE `/meta/object/crm_lead` over a legacy row | 200 | **403** | 403 | 403 | 403 | | DELETE `/meta/object/crm_lead?dropStorage=true` | 200, and the object leaves the data plane (POST `/data/crm_lead` 404 OBJECT_NOT_FOUND, GET 500 DATABASE_ERROR) | **403**, and the data plane stays up (201 / 200) | not run | not run | not run | | DELETE `/meta/permission`, `/meta/position` over a legacy row | 200 | 200 (the #6960 repair, kept) | 200 | 200 | 200 | | DISP envelope of the shipped flow, `editable` / `deletable` | true / true | **false / false** | true / true | **false / false** | false / false | | `/meta/types` entry for `flow`, `allowOrgOverride` / `overrideSource` | true / env | true / env (Q2) | true / env | true / env | false / registry | Controls, measured in every mode, on both transports, with base equal to head: - PUT of a view overlay (`crm_opportunity.all`): 200. Its REST DELETE: 200. - PUT `/meta/flow/NEW`: 200. - POST `/automation` (create): 200. - Toggle off, then on: 200 / 200. - Clone: 200, and the clone carries no linkage keys. - PUT `/meta/job/NEW`: 403 NOT_CREATABLE under NONE, OS and LEGACY; 200 under JOB, unchanged (M6, out of scope, below). **M2: the two hatch readers diverge on the legacy spelling.** This is measured. - The protocol's reader, `envWritableTypes()`, reads both spellings through `readEnvWithDeprecation`. - The repository's reader, `envWritableMetadataTypes()`, reads `OS_METADATA_WRITABLE` only. At base, the legacy spelling advertised managed items as writable, while every write onto them answered 403: - the listing read `allowOrgOverride: true`; - the DISP envelope read `editable: true`; - every write answered 403, because the repository's hatch never opened. At head the seal makes both readers irrelevant for managed items, and the envelope reads false under both spellings. For creating an item no package ships, the divergence remains, and it is reported below. **M4.** This PR does not touch the toggle path or its gate. The gate is measured by `automation-activation-posture-gate.test.ts` and `action-activation-posture-gate.test.ts`: 2 files, 46 passed. In the `group` and `isolated` postures a tenant admin is refused and the operator is allowed; enable is gated as well as disable; the clone door is not gated. In the `single` posture the gate is inert, which is why the CRM boot reads 200 on the toggle. As measured above, the clone carries no linkage, and a new flow and POST `/automation` answer 200. **M5.** A position overlay through the hatch is refused at save time (the table above). ## Pins New: - `packages/rest/src/rest-meta-managed-seal-hatch.test.ts`, 6 cases. The REST door relays the seal for flow, object, field, permission and position, under both spellings and both kernel shapes. Each answer is byte-equal to the hatch-shut answer and names "managed package". It also covers DELETE of a flow and an object. - `packages/runtime/src/meta-managed-content-seal.test.ts`, 12 cases. It drives the real `HttpDispatcher`, protocol and repository: - a PUT is refused and writes no row; - the GET envelope reads `editable` / `deletable` false; - controls: a view overlay and a new flow answer 200. - `packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts`, 10 cases, on CRM under the OS hatch: - the premise: the listing advertises the hatch; - PUT of a flow, object, permission and position, each refused with no row; - PUT `/automation` refused; - DELETE of a flow and an object over a legacy row, refused with the row kept; - `dropStorage` refused with the data plane up; - the #6960 removal still 200; - controls: a view overlay, a new flow (both doors), toggle, and clone without linkage. Re-premised: the existing pins that carried "the hatch opens a managed item" now pin the seal. They are in `metadata-protocol` (10 files), `objectql` (5), `rest` (3), `runtime` (2), and the dogfood showcase scalar-divergence file. That file now seeds its pre-seal rename as a legacy row and cold-boots, so its read assertions keep a premise. Patch round 1 adds two more: the plugin-security lock-gate cases, and #22365's cold-boot catalog control (below). ## Reverse verification, at `c2d18e52f5`, under a trap restore Two mutations reopen the hatch for managed items: - In the protocol, `registryAllowsOverlay` also reads `envWritableTypes()`. - In the repository, `if (hatchOpen && intent === 'runtime-only') return;` becomes `if (hatchOpen) return;`. Both landed on disk (`ablation-replace`: anchor 1 → 0, blob changed). Both reached `dist/`: the preflight found the marker in 2 built files. | Suite | Mutated | Restored | |:--|:--|:--| | metadata-protocol seal pins | 13 failed / 72 passed | 85 / 85 | | rest | 6 failed / 6 | 6 / 6 | | runtime | 8 failed / 4 passed (the 4 passing are the controls) | 12 / 12 | | dogfood | 4 failed / 6 passed (the 6 passing are the premise, #6960, view, new flow, toggle and clone) | 10 / 10 | The restore was proved three ways: - each blob equals HEAD (`6df9a994bc36` and `690b710cc415`); - `git diff HEAD` is empty and the working tree is clean; - after a rebuild, `--absent` passed for both markers. The direction was an ordinary red. ## Changeset and ADR-0087 The changeset is `.changeset/15206-managed-content-sealed.md`: `@objectstack/metadata-protocol` minor, with the BREAKING paragraph for the v18 prerelease line. Changesets is in pre mode. The ADR-0087 marker is `not-required (no-migration-prescription)`: no spec key, stored shape or export changes, and every stored row loads and serves unchanged. `check:adr-0087-registration` reads it as `[BREAKING+bang+clause-②-narrowing] not-required`. The `OS_METADATA_WRITABLE` row in `content/docs/deployment/environment-variables.mdx` now says what the hatch opens and what it never opens, and lists the sanctioned route for each type. ## Tests and gates Package suites, at `63ea4b2a32`: `origin/main` `11d119ab18` merged, plus the plugin-security test change. `49f00d1b39` then changed one dogfood test file only, and shard 2/3 was re-run there. | Suite | Files | Tests | |:--|:--|:--| | `@objectstack/metadata-protocol` | 223 passed + 3 skipped | 28323 passed + 19 skipped | | `@objectstack/rest` | 270 passed | 5151 passed + 327 skipped | | `@objectstack/runtime` | 345 passed | 5551 passed + 19 skipped | | `@objectstack/objectql` | 390 passed | 7666 passed | | `@objectstack/plugin-security` | 187 passed | 3915 passed + 45 skipped | Dogfood shards: - shard 1/3, at `63ea4b2a32`: 78 files, 574 passed; - shard 2/3, at `49f00d1b39`: 77 files, 551 passed + 1 skipped; - shard 3/3, at `63ea4b2a32`: 76 files + 1 skipped file, 679 passed + 8 skipped. `typecheck` exits 0 for plugin-security (its test layer included) and dogfood at `49f00d1b39`, and for metadata-protocol, rest, runtime and objectql in the first round. No source in `metadata-protocol` has changed since `c2d18e52f5`. Gate families come from `node scripts/pm/dispatch-gates.mjs --commands`, run with no paths at `49f00d1b39`. All 109 commands ran, and every one exits 0. `--ran` reconciles: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN. **Lint.** CI owns the repo-wide lint. Here, a narrowed run of `eslint --no-inline-config --format json` over the diff's 29 `.ts` files, at `49f00d1b39`, gives 29 files, 0 errors and 0 warnings. The proof that narrowing excludes nothing: - The population comes from eslint's own config: `eslint.config.mjs:971` matches `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. - The file count comes from the JSON output. - Type-aware linting is never enabled (`eslint.config.mjs:327-328`), so this diff cannot move the verdict on any file it does not touch. ## Serial constraints - S1, #22374, is merged into this branch (merge `3c4873ce32`). - #22323: none of its lines are touched. `git merge-tree` against its head `81cd9291bc` exits 0, and its hunks (protocol.ts around 22697-22780; the repository around 81, 195, 1318, 1337) do not overlap. - `origin/main` `11d119ab18` is merged (merge `d68163d1fc`). `origin/main` has moved since, to `27a8b33dec` (12 commits); `git merge-tree` against it exits 0. - #22416 (`domain:services`) also edits `security-catalog-cold-boot-environment-holder.dogfood.test.ts`, at its `mkdtempSync` line and its cleanup. This PR leaves those lines alone. #22416 has since landed on `main`; `git merge-tree` against `main` `27a8b33dec`, which contains it, exits 0. ## Open questions **Q1: legacy overlay rows of types that do not merge at read are stranded.** These are flow, action, hook, object and similar types. A row the hatch wrote earlier keeps serving, and can no longer be edited or removed through `/meta`, with the hatch set or not. - **A, as ruled and implemented:** no `/meta` route removes such a row. - **B, as a separate decision:** extend the #6960 repair so that removing an existing stored overlay row of any managed item is allowed, while `dropStorage` of a managed object stays refused. This touches ADR-0029 D9.6 ("the hatch … the one door for the life of the customization") and the ADR-0086 D1 env-overlay tighten path. Both texts now describe a door that is shut; that is a Tier H edit. Triage answered Q1 with C: S2 stays as built, and S5 reports hatch-written environment overlay rows on sealed items at boot and stops serving them. Nothing changes for Q1 in this PR. **Q2:** the `/meta/types` listing still reports `allowOrgOverride: true, overrideSource: 'env'` for a type named in the hatch. Studio's per-item lock reads the envelope, which is now correct. The listing flag is about the type, not the item. The carrier is the rename in #22340. Q2 was answered A: the flag stays, because the per-item envelope is the truthful signal and the key belongs to #22340. ## Acceptance notes Out of scope. These are reported for the seat to file; nothing was filed here. - **The hatch opens runtime creation of code-only types (M6), class b.** Reach: on CRM, `OS_METADATA_WRITABLE=job`, PUT `/api/v1/meta/job/s2_job_job_rest` answers 200 "Saved job 's2_job_job_rest' (env-wide, state=active)". This holds at base and at head, on both transports. - **The legacy spelling diverges between the two readers, class a.** Reach: under `OBJECTSTACK_METADATA_WRITABLE=job`, PUT `/meta/job/NEW` answers 403 NOT_CREATABLE and prescribes `OS_METADATA_WRITABLE`, while the protocol's own reader honours the legacy spelling. Carriers, noted, not filed. These are dead or stale after the seal: - dead or unreachable code: - the write-side `OBJECT_OVERLAY_PACKAGE_MISMATCH`; - the packaged-baseline R1 branch in plugin-security `object-posture-gate`; - `DELETE_RESTRICTED` at the `/automation` door; - the plugin-security lock gate's metadata-door registration; - the D9.7 subtraction through `/meta`; - stale comments: - `runtime/src/domains/automation.ts:1488`; - in plugin-security, declared to `domain:services` as theirs to carry: the `packaged-permission-set-lock-gate.ts` header, `permission-set-projection.ts` (around 1203, 1301, 1393), `object-posture-gate.ts:14` and `:93`, and `security-plugin.ts:4841`; - stale docs: - `permission-sets.mdx:363`; - `plugins/adding-a-metadata-type.mdx:59-63`; - `docs/qa/platform-checklist/areas/studio-authoring.json` (it says the hatch clears the read-only badge). ## Cross-lane paths These are outside the card's declared surface, and each is a test re-premised by the seal or a new pin: - rest: - `packages/rest/src/meta-object-owd-gate.test.ts` - `rest-meta-packaged-action-permission-refusal.test.ts` - `rest-meta-packaged-flow-refusal.test.ts` - `rest-meta-managed-seal-hatch.test.ts` (new) - runtime: - `packages/runtime/src/domains/automation-packaged-base-lock.test.ts` - `meta-overlay-read-your-writes.test.ts` - `meta-managed-content-seal.test.ts` (new) - objectql: - `packages/objectql/src/protocol-commit-history.test.ts` - `protocol-destructive.test.ts` - `protocol-meta.test.ts` - `protocol-object-overlay-layer.test.ts` - `protocol-registry-shadow.test.ts` - dogfood: - `packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts` - `managed-content-sealed.dogfood.test.ts` (new) - `security-catalog-cold-boot-environment-holder.dogfood.test.ts` (from #22365, re-premised in Patch round 1; `domain:cli`, declared by the seat; #22416 edits other lines of it) - plugin-security (declared to `domain:services` on #6021): - `packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts` - docs (declared to `domain:devx` on #6023): - `content/docs/permissions/authorization.mdx` (Patch round 2) - `scripts/engine-double-contract.pinned.json` (three rows for the new runtime double) ## Patch round 1 - **The declared test change.** `packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts` was declared to `domain:services` on #6021. Its two hatch-OPEN cases ("a package-less save targeting a package-declared set", and "a DRAFT save of the packaged name") are retitled "refused by the protocol package door". Each now asserts `not.toBeInstanceOf(PackagedPermissionSetLockedError)`, keeps `toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 })` and the no-row assertion, and no longer asserts that the message contains the package id. The header says which layer answers. No plugin-security source file changes, and no other plugin-security file. plugin-security is now green: 187 files, 3915 passed + 45 skipped. - **`origin/main` merged** (`11d119ab18`, merge `d68163d1fc`, no conflict). The merge brought #22365's `security-catalog-cold-boot-environment-holder.dogfood.test.ts`, which went red in shard 2/3: - Its CONTROL case saved stored definitions under the built-in positions `org_admin` and `everyone` through `OS_METADATA_WRITABLE=position`, and expected 200. - Those positions ship in the platform's own package, so the seal now answers `403 NOT_OVERRIDABLE`. This is the same M5 refusal as above. - The control now pins that refusal. It writes the two rows at the driver, the way an older release left them, as the same file's legacy-row case already does. Its own assertions are kept: the restart boots, and the stored definition answers. - The file is 4 / 4 green, and shard 2/3 is green at `49f00d1b39`. - The `mkdtempSync` line and the cleanup are untouched; #22416 owns them. #22416 has not landed, and the two merge cleanly. - **M4** now carries the toggle-gate measurement (46 pins). - **Q2** answered A. **Q1** answered C by triage (S5 carries it). Nothing changes for either in this round. ## Patch round 2 The contract review found one published sentence this PR made false: the "Runtime OWD posture gate" bullet in `content/docs/permissions/authorization.mdx` (about lines 480-486). At this head, an environment overlay of a packaged object is refused `403 NOT_OVERRIDABLE` in both directions, hatch open or shut; `packages/rest/src/meta-object-owd-gate.test.ts` pins exactly that. The page was declared to `domain:devx` on #6023. Only that sentence changed (commit `09eff73814`); nothing else on the page, and no code or test. Before: ```text An environment overlay of a **packaged** object may only *tighten* `sharingModel` / `externalSharingModel`, never widen them beyond the packaged declaration (`403 owd_widening_forbidden` — widen it in the package source and publish instead; this closes the `OS_METADATA_WRITABLE=object` escape hatch as an unvalidated widening path, ADR-0086 D1). ``` After: ```text An environment overlay of a **packaged** object is refused outright, whether it tightens or widens `sharingModel` / `externalSharingModel`, and with `OS_METADATA_WRITABLE` set or not (`403 NOT_OVERRIDABLE` — managed content is sealed, ADR-0131 D6): change the posture in the package source and publish instead. The package door answers ahead of this gate, so its packaged-baseline rule (`403 owd_widening_forbidden`, ADR-0086 D1) is not reached through the metadata API. ``` The route named is the one the bullet already used, and the one the page names for packaged permission sets: change the package source and publish. An object has no linkage-free clone. The bullet's other sentences are unchanged and still true: the gate is registered on the seam, the write path only is gated, and R2 is retired. Gates at `09eff73814`. `dispatch-gates --commands`, run with no paths, derives 110 commands: round 1's 109 plus `check:merge-driver`. All 110 exit 0, and `--ran` reconciles 110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN. That set includes every docs family: - `check-doc-frontmatter` and its self-test; - `check:doc-authoring` and `check:doc-anchors`; - `check:docs-redirects`, `check:docs-single-h1`, `check:docs-audit-scope`, `check:docs-transcript-drift` and `check:docs-spec-enumerations`; - `check-affected-docs` and `check:published-readme-links`. Outside the derived set, `check:docs-locale-catch-all`, `check:docs-image-tag`, `check:docs-image-tag-sync` and `check:adr-links` also exit 0. #22416 has since landed on `main`. `git merge-tree` of this head against `main` `27a8b33dec`, which contains it, exits 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c64130b commit e02833c

33 files changed

Lines changed: 1680 additions & 664 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
feat(metadata-protocol)!: managed content is sealed — `OS_METADATA_WRITABLE` no longer opens a write onto, or a removal of, an item a managed package ships (ADR-0131 D6)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the change narrows what an operator environment variable opens at the runtime doors; no spec key, stored metadata shape or export a manifest names changes, every stored row loads and serves unchanged, and objectstack migrate meta has nothing to rewrite -->
10+
11+
**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`.
12+
13+
ADR-0131 D6: no door edits a managed definition. The operator hatch `OS_METADATA_WRITABLE` (and its legacy spelling `OBJECTSTACK_METADATA_WRITABLE`) used to open one: an item a managed package ships, of a type whose registry entry allows no environment overlay, could be overlaid and its overlay row removed once the type was named in the variable. Measured on the CRM example with `OS_METADATA_WRITABLE=flow,object,permission,position`, a shipped flow, an object field and a position each took an overlay row, `PUT /api/v1/automation/:name` re-registered the shipped flow, and `DELETE /api/v1/meta/object/crm_lead?dropStorage=true` took the managed object off the data plane.
14+
15+
**What changes.** With the hatch open or shut, the metadata doors now answer the same thing for an item a managed package ships: `403 NOT_OVERRIDABLE` on a write, and on a removal of a type whose overlay does not merge at read. The protocol's two package doors (`PUT` / `DELETE /api/v1/meta/:type/:name`, and the `/automation` definition doors that ask them), the repository's write path (draft promotion, restore, revert) and the read envelope (`editable` / `deletable`) all read one predicate: the item is artifact-backed and its type's registry entry opens no overlay channel.
16+
17+
- The refusal's first sentence names the managed package ("… is provided by a managed package and is sealed …"). A type with an ADR-0126 regime row (`flow`, `action`, `permission`) still names its sanctioned route, the clone or the switch. Every other type now reads the seal, says the hatch does not open a managed item, and keeps the source remedy; it no longer prescribes setting `OS_METADATA_WRITABLE`, which would no longer help.
18+
- A write naming a read-only package (`?package=`) still answers `403 ITEM_LOCKED`, now without a hatch-dependent remedy. `SysMetadataRepository.readOnlyBaseOverrideError` takes `(type, packageId)`; its third `hatchOpen` parameter is gone, because nothing it chose survives the seal.
19+
20+
**What does not change.**
21+
22+
- The environment overlay of a `view`, `dashboard`, `report`, `translation` or `email_template` a package ships, which the registry allows.
23+
- Everything about items no managed package ships: the hatch still opens their runtime creation for a type that allows none, and their organization-scoped write.
24+
- Disabling a managed flow or action (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/:object/:action`), operator-gated under a wall as before, and cloning a flow under a new name, which records no linkage.
25+
- Removing a stored overlay row of a type whose loader merges it at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`): that removal restores the package's definition and stays allowed.
26+
- Every overlay row a deployment already holds keeps loading and serving as before.
27+
28+
**For an operator who set the hatch to customize a managed item.** Customize it through its type's route: an environment overlay for the five presentational types, the switch or a clone under a new name for a flow, the clone for a permission set, an extension package for an object. An overlay row the hatch wrote earlier onto a flow, action, hook, object or another type whose overlay does not merge at read keeps serving, and can no longer be edited or removed through the metadata API, with the hatch set or not.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from
306306
| `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. |
307307
| `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). |
308308
| `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. |
309-
| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,validation`) granted a runtime escape hatch that treats them as `allowOrgOverride: true`, letting artifact-backed items of those protected types be overridden per-org outside their static registry declaration. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). |
309+
| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none, and an organization-scoped write of a type with no per-organization channel. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. Overlay rows this hatch wrote before are still served, and a row of a type that merges overlays at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed to restore the package's definition. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). |
310310

311311
---
312312

‎content/docs/permissions/authorization.mdx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -479,11 +479,13 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
479479
pre-persistence `registerAuthoringGate` seam): the packaged-baseline rule
480480
no lint rule can judge, enforced on every runtime-authored object body —
481481
Studio drafts, REST saves, AI builders. An environment overlay of a
482-
**packaged** object may only *tighten* `sharingModel` /
483-
`externalSharingModel`, never widen them beyond the packaged declaration
484-
(`403 owd_widening_forbidden` — widen it in the package source and
485-
publish instead; this closes the `OS_METADATA_WRITABLE=object` escape
486-
hatch as an unvalidated widening path, ADR-0086 D1). Write-path only:
482+
**packaged** object is refused outright, whether it tightens or widens
483+
`sharingModel` / `externalSharingModel`, and with `OS_METADATA_WRITABLE`
484+
set or not (`403 NOT_OVERRIDABLE` — managed content is sealed, ADR-0131
485+
D6): change the posture in the package source and publish instead. The
486+
package door answers ahead of this gate, so its packaged-baseline rule
487+
(`403 owd_widening_forbidden`, ADR-0086 D1) is not reached through the
488+
metadata API. Write-path only:
487489
stored metadata keeps loading unchanged. The gate's former second rule
488490
(`403 owd_external_wider`, external ≤ internal) was **retired as a
489491
duplicate** when the lint block crossed to the runtime door (#8310

‎packages/metadata-protocol/src/packaged-base-regime.ts‎

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,26 @@
8989
* persists it, so a stored row under its name is never a layer of it — it is
9090
* residue a runtime write left — and removing it restores the code definition.
9191
*
92-
* ⛔ No sentence built here names the `OS_METADATA_WRITABLE` hatch. The hatch
93-
* still opens these locks exactly as before, so which writes are refused does
94-
* not move — only what the refusal prescribes. ⛔ Nor does a Regime C sentence
95-
* prescribe editing the source and redeploying: the administrator of an
96-
* installed package cannot do that, and a Regime C type has a runtime route
97-
* instead. The origin-gated row has none, so the source is the only remedy
98-
* there is to name.
92+
* ⛔ No sentence built here prescribes the `OS_METADATA_WRITABLE` hatch. [ADR-0131
93+
* D6] Managed content is sealed: the hatch opens no write onto, and no removal
94+
* of, an item a managed package ships, on any door, so a sentence that named it
95+
* as a remedy would send the operator to a door that does not open. The one
96+
* sentence that names it at all is {@link managedItemSealedSentence}'s, for a
97+
* type with no regime row, and it names it to say it does not apply: an operator
98+
* who set it and read a refusal that never mentioned it would conclude it was
99+
* ignored and set it again. ⛔ Nor does a Regime C sentence prescribe editing the
100+
* source and redeploying: the administrator of an installed package cannot do
101+
* that, and a Regime C type has a runtime route instead. The origin-gated row
102+
* has none, so the source is the only remedy there is to name.
103+
*
104+
* ## "A managed package"
105+
*
106+
* [ADR-0131 D6] A package reaches a deployment in one of two install modes, and
107+
* only the managed one registers its content as code. The install mode is not
108+
* modelled yet (the manifest declaration of permitted modes is later work), so
109+
* every package whose items the artifact loader registered is managed, and every
110+
* sentence here names it so: the install mode is what the refusal is about, not
111+
* the fact that the item happens to be code.
99112
*/
100113

101114
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
@@ -262,20 +275,20 @@ export function isOriginGatedType(type: string): boolean {
262275
* `undefined` when the type declares no regime and the emitter keeps its own
263276
* sentence. Read on the canonical type, and spoken with it.
264277
*
265-
* The lock is the regime's: a Regime C item "is provided by a code package, and
266-
* its packaged base is locked"; an origin-gated item "is code-defined and cannot
267-
* be edited (removed) at runtime: it is read-only" — the datasource-admin
268-
* service's own verdict on the same item, so the two doors onto one code-defined
269-
* datasource state one verdict and one remedy.
278+
* The lock is the regime's: a Regime C item "is provided by a managed package and
279+
* is sealed" ([ADR-0131 D6] — the install mode, see the module header); an
280+
* origin-gated item "is code-defined and cannot be edited (removed) at runtime:
281+
* it is read-only" — the datasource-admin service's own verdict on the same item,
282+
* so the two doors onto one code-defined datasource state one verdict and one
283+
* remedy.
270284
*
271285
* Kept under the REST door's 500-character client-message bound
272286
* (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which
273-
* the tail is truncated. Characters before the item's name, save / removal:
274-
* `flow` 411 / 404, `action` 365 / 358, `permission` 317 / 310, `datasource`
275-
* 192 / 193 — so a name of up to 88 characters arrives whole for every row
276-
* (pinned). A `flow`'s sentence is byte-identical to the one the row table
277-
* replaced (pinned literally). [#21944] A row's `hostOwned` name is a fixed,
278-
* short name with its own remedy (`default`: under 300 characters whole).
287+
* the tail is truncated. Characters outside the item's name, save / removal:
288+
* `flow` 395 / 388, `action` 349 / 342, `permission` 301 / 294,
289+
* `datasource` 192 / 193 — so a name of up to 88 characters arrives whole for
290+
* every row (pinned). [#21944] A row's `hostOwned` name is a fixed, short name
291+
* with its own remedy (`default`: under 300 characters whole).
279292
*/
280293
export function packagedBaseRegimeSentence(
281294
type: string, name: string, operation: 'save' | 'delete',
@@ -286,7 +299,43 @@ export function packagedBaseRegimeSentence(
286299
const lock = row.regime === 'origin-gated'
287300
? `${row.noun} '${name}' is code-defined and cannot be `
288301
+ (operation === 'delete' ? 'removed' : 'edited') + ' at runtime: it is read-only. '
289-
: `Metadata item '${singular}/${name}' is provided by a code package, and its packaged base is locked `
302+
: `Metadata item '${singular}/${name}' is provided by a managed package and is sealed `
290303
+ (operation === 'delete' ? `against removal. ` : `against in-place edits. `);
291304
return lock + rowPrescription(row, name);
292305
}
306+
307+
/** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */
308+
const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md';
309+
310+
/**
311+
* [ADR-0131 D6] THE refusal sentence for a write onto, or a removal of, an item a
312+
* managed package ships, on a type with no environment overlay — every door that
313+
* refuses one builds it here, and nowhere else: the metadata protocol's package
314+
* doors (`refusePackagedBaseOverride` / `refusePackagedBaseRemoval`) and the
315+
* repository's type door (`SysMetadataRepository.assertAllowed`), which answers
316+
* the same condition one layer down for the writes that reach it without passing
317+
* a package door (draft promotion, restore, revert).
318+
*
319+
* A type with a regime row speaks for its regime ({@link packagedBaseRegimeSentence}:
320+
* the sanctioned path, never the hatch). Every other type reads the managed seal
321+
* itself: the item is sealed, its type takes no environment overlay, the
322+
* `OS_METADATA_WRITABLE` hatch does not open it, and the one remedy that exists —
323+
* changing the definition where it is declared. The hatch is named to say it
324+
* does not apply (see the module header for why it is named at all), and the
325+
* registry flag that produced the verdict is named so the reader can tell this
326+
* refusal from the regime-O overlay it is not.
327+
*
328+
* Kept under the REST door's 500-character client-message bound: the sentence
329+
* outside the item's type and name is 321 / 275 characters, save / removal (measured).
330+
*/
331+
export function managedItemSealedSentence(type: string, name: string, operation: 'save' | 'delete'): string {
332+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
333+
const regime = packagedBaseRegimeSentence(singular, name, operation);
334+
if (regime !== undefined) return regime;
335+
return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed `
336+
+ (operation === 'delete' ? 'against removal' : 'against in-place edits')
337+
+ `: its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not `
338+
+ `open a managed item. `
339+
+ (operation === 'delete' ? '' : 'Edit the source artifact and redeploy. ')
340+
+ `See ${MANAGED_SEAL_ADR}.`;
341+
}

‎packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,13 +372,28 @@ for (const { label, environmentId } of KERNELS) {
372372
expect(rows.size).toBe(1);
373373
});
374374

375-
it('[GUARD] the operator hatch opens the lock exactly as before (not this card\'s to move)', async () => {
375+
it('[ADR-0131 D6] the operator hatch no longer opens the lock: a code-defined datasource is sealed with it open', async () => {
376+
// Was `[GUARD] the operator hatch opens the lock exactly as before`:
377+
// the save landed a row. Managed content is sealed now, so the hatch
378+
// answers what the shut hatch answers — this door's own verdict and
379+
// sentence — and nothing is written. A runtime datasource keeps its
380+
// write (the control below).
376381
process.env.OS_METADATA_WRITABLE = 'datasource';
377382
ObjectStackProtocolImplementation.resetEnvWritableCache();
378383
resetEnvWritableMetadataTypes();
379384
const { protocol, rows } = session();
380-
const saved = await protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Hatch') });
381-
expect(saved).toMatchObject({ success: true });
385+
const err: any = await protocol
386+
.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Hatch') })
387+
.then(() => null, (e: unknown) => e);
388+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
389+
expect(String(err?.message)).toBe(
390+
`Datasource '${CODE_DS}' is code-defined and cannot be edited at runtime: it is read-only. `
391+
+ 'Edit the *.datasource.ts source that declares it and redeploy. '
392+
+ 'See docs/adr/0062-external-datasource-runtime.md.',
393+
);
394+
expect(rows.size).toBe(0);
395+
const runtime = await protocol.saveMetaItem({ type: 'datasource', name: RUNTIME_DS, item: body(RUNTIME_DS, 'Hatch') });
396+
expect(runtime).toMatchObject({ success: true });
382397
expect(rows.size).toBe(1);
383398
});
384399
});

0 commit comments

Comments
 (0)