diff --git a/.changeset/15206-managed-content-sealed.md b/.changeset/15206-managed-content-sealed.md new file mode 100644 index 00000000000..deb7db5b9b5 --- /dev/null +++ b/.changeset/15206-managed-content-sealed.md @@ -0,0 +1,28 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +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) + +Clause-②: no (narrowing) + + + +**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`. + +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. + +**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. + +- 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. +- 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. + +**What does not change.** + +- The environment overlay of a `view`, `dashboard`, `report`, `translation` or `email_template` a package ships, which the registry allows. +- 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. +- 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. +- 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. +- Every overlay row a deployment already holds keeps loading and serving as before. + +**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. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 37539832bca..d1a6eb96b00 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from | `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. | | `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). | | `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. | -| `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). | +| `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). | --- diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 831de141e0a..426e851a9cb 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -479,11 +479,13 @@ Five mechanisms — four CI-time, one runtime — make the security posture a pre-persistence `registerAuthoringGate` seam): the packaged-baseline rule no lint rule can judge, enforced on every runtime-authored object body — Studio drafts, REST saves, AI builders. 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). Write-path only: + **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. Write-path only: stored metadata keeps loading unchanged. The gate's former second rule (`403 owd_external_wider`, external ≤ internal) was **retired as a duplicate** when the lint block crossed to the runtime door (#8310 diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index 120c02c411d..6cd2bc78d54 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -89,13 +89,26 @@ * persists it, so a stored row under its name is never a layer of it — it is * residue a runtime write left — and removing it restores the code definition. * - * ⛔ No sentence built here names the `OS_METADATA_WRITABLE` hatch. The hatch - * still opens these locks exactly as before, so which writes are refused does - * not move — only what the refusal prescribes. ⛔ Nor does a Regime C sentence - * prescribe editing the source and redeploying: the administrator of an - * installed package cannot do that, and a Regime C type has a runtime route - * instead. The origin-gated row has none, so the source is the only remedy - * there is to name. + * ⛔ No sentence built here prescribes the `OS_METADATA_WRITABLE` hatch. [ADR-0131 + * D6] Managed content is sealed: the hatch opens no write onto, and no removal + * of, an item a managed package ships, on any door, so a sentence that named it + * as a remedy would send the operator to a door that does not open. The one + * sentence that names it at all is {@link managedItemSealedSentence}'s, for a + * type with no regime row, and it names it to say it does not apply: an operator + * who set it and read a refusal that never mentioned it would conclude it was + * ignored and set it again. ⛔ Nor does a Regime C sentence prescribe editing the + * source and redeploying: the administrator of an installed package cannot do + * that, and a Regime C type has a runtime route instead. The origin-gated row + * has none, so the source is the only remedy there is to name. + * + * ## "A managed package" + * + * [ADR-0131 D6] A package reaches a deployment in one of two install modes, and + * only the managed one registers its content as code. The install mode is not + * modelled yet (the manifest declaration of permitted modes is later work), so + * every package whose items the artifact loader registered is managed, and every + * sentence here names it so: the install mode is what the refusal is about, not + * the fact that the item happens to be code. */ import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; @@ -262,20 +275,20 @@ export function isOriginGatedType(type: string): boolean { * `undefined` when the type declares no regime and the emitter keeps its own * sentence. Read on the canonical type, and spoken with it. * - * The lock is the regime's: a Regime C item "is provided by a code package, and - * its packaged base is locked"; an origin-gated item "is code-defined and cannot - * be edited (removed) at runtime: it is read-only" — the datasource-admin - * service's own verdict on the same item, so the two doors onto one code-defined - * datasource state one verdict and one remedy. + * The lock is the regime's: a Regime C item "is provided by a managed package and + * is sealed" ([ADR-0131 D6] — the install mode, see the module header); an + * origin-gated item "is code-defined and cannot be edited (removed) at runtime: + * it is read-only" — the datasource-admin service's own verdict on the same item, + * so the two doors onto one code-defined datasource state one verdict and one + * remedy. * * Kept under the REST door's 500-character client-message bound * (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which - * the tail is truncated. Characters before the item's name, save / removal: - * `flow` 411 / 404, `action` 365 / 358, `permission` 317 / 310, `datasource` - * 192 / 193 — so a name of up to 88 characters arrives whole for every row - * (pinned). A `flow`'s sentence is byte-identical to the one the row table - * replaced (pinned literally). [#21944] A row's `hostOwned` name is a fixed, - * short name with its own remedy (`default`: under 300 characters whole). + * the tail is truncated. Characters outside the item's name, save / removal: + * `flow` 395 / 388, `action` 349 / 342, `permission` 301 / 294, + * `datasource` 192 / 193 — so a name of up to 88 characters arrives whole for + * every row (pinned). [#21944] A row's `hostOwned` name is a fixed, short name + * with its own remedy (`default`: under 300 characters whole). */ export function packagedBaseRegimeSentence( type: string, name: string, operation: 'save' | 'delete', @@ -286,7 +299,43 @@ export function packagedBaseRegimeSentence( const lock = row.regime === 'origin-gated' ? `${row.noun} '${name}' is code-defined and cannot be ` + (operation === 'delete' ? 'removed' : 'edited') + ' at runtime: it is read-only. ' - : `Metadata item '${singular}/${name}' is provided by a code package, and its packaged base is locked ` + : `Metadata item '${singular}/${name}' is provided by a managed package and is sealed ` + (operation === 'delete' ? `against removal. ` : `against in-place edits. `); return lock + rowPrescription(row, name); } + +/** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */ +const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md'; + +/** + * [ADR-0131 D6] THE refusal sentence for a write onto, or a removal of, an item a + * managed package ships, on a type with no environment overlay — every door that + * refuses one builds it here, and nowhere else: the metadata protocol's package + * doors (`refusePackagedBaseOverride` / `refusePackagedBaseRemoval`) and the + * repository's type door (`SysMetadataRepository.assertAllowed`), which answers + * the same condition one layer down for the writes that reach it without passing + * a package door (draft promotion, restore, revert). + * + * A type with a regime row speaks for its regime ({@link packagedBaseRegimeSentence}: + * the sanctioned path, never the hatch). Every other type reads the managed seal + * itself: the item is sealed, its type takes no environment overlay, the + * `OS_METADATA_WRITABLE` hatch does not open it, and the one remedy that exists — + * changing the definition where it is declared. The hatch is named to say it + * does not apply (see the module header for why it is named at all), and the + * registry flag that produced the verdict is named so the reader can tell this + * refusal from the regime-O overlay it is not. + * + * Kept under the REST door's 500-character client-message bound: the sentence + * outside the item's type and name is 321 / 275 characters, save / removal (measured). + */ +export function managedItemSealedSentence(type: string, name: string, operation: 'save' | 'delete'): string { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + const regime = packagedBaseRegimeSentence(singular, name, operation); + if (regime !== undefined) return regime; + return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed ` + + (operation === 'delete' ? 'against removal' : 'against in-place edits') + + `: its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not ` + + `open a managed item. ` + + (operation === 'delete' ? '' : 'Edit the source artifact and redeploy. ') + + `See ${MANAGED_SEAL_ADR}.`; +} diff --git a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts index aa8d986a5d7..32d8e5c439e 100644 --- a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts +++ b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts @@ -372,13 +372,28 @@ for (const { label, environmentId } of KERNELS) { expect(rows.size).toBe(1); }); - it('[GUARD] the operator hatch opens the lock exactly as before (not this card\'s to move)', async () => { + it('[ADR-0131 D6] the operator hatch no longer opens the lock: a code-defined datasource is sealed with it open', async () => { + // Was `[GUARD] the operator hatch opens the lock exactly as before`: + // the save landed a row. Managed content is sealed now, so the hatch + // answers what the shut hatch answers — this door's own verdict and + // sentence — and nothing is written. A runtime datasource keeps its + // write (the control below). process.env.OS_METADATA_WRITABLE = 'datasource'; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); const { protocol, rows } = session(); - const saved = await protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Hatch') }); - expect(saved).toMatchObject({ success: true }); + const err: any = await protocol + .saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Hatch') }) + .then(() => null, (e: unknown) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(err?.message)).toBe( + `Datasource '${CODE_DS}' is code-defined and cannot be edited at runtime: it is read-only. ` + + 'Edit the *.datasource.ts source that declares it and redeploy. ' + + 'See docs/adr/0062-external-datasource-runtime.md.', + ); + expect(rows.size).toBe(0); + const runtime = await protocol.saveMetaItem({ type: 'datasource', name: RUNTIME_DS, item: body(RUNTIME_DS, 'Hatch') }); + expect(runtime).toMatchObject({ success: true }); expect(rows.size).toBe(1); }); }); diff --git a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts index 83143a1766f..e3e4500e851 100644 --- a/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-receipt-wording.test.ts @@ -225,37 +225,32 @@ describe('#5927 — a delete receipt names what actually happened', () => { ); }); - it('an overlay of a packaged ACTION — supportsOverlay:false, and still a real reset', async () => { - // The mirror case, and the sharpest one. `action` declares - // `supportsOverlay: false` yet is `allowOrgOverride: true`, so a - // packaged action really can be overridden at runtime — and then - // lifting that overlay really does restore the packaged default. A - // receipt decided by `supportsOverlay` would get this exactly - // backwards; one decided by artifact backing gets it right. - // - // The specimen was `flow` until #6283 rolled that type's - // `allowOrgOverride` back to `false` (ADR-0005:57), then bare - // `action` until commit ee58392e1 rolled back the remaining nine unratified - // flags — the premise pin above now holds the population EMPTY. The - // pairing stays reachable through `OS_METADATA_WRITABLE` (ADR-0005's - // documented operator escape hatch, consulted by both write gates), - // and an overlay row minted under the hatch — or under the old flag, - // pre-rollback — still exists at delete time, so the reset sentence - // this case pins is still a sentence real deployments will read. + it('an overlay of a packaged ACTION — supportsOverlay:false: no longer removable through the hatch (ADR-0131 D6)', async () => { + // This case used to pin the RESET sentence for the one pairing that + // reached it: a packaged item of a `supportsOverlay: false` type whose + // overlay row the `OS_METADATA_WRITABLE` hatch let the delete remove. + // Managed content is sealed now, so the hatch opens no removal of an + // item a managed package ships, and the pairing has no door: the delete + // is refused with the seal and the row stays. The reset sentence keeps + // its pin on the overlay of a packaged VIEW (the case above), the + // regime-O type whose overlay the registry allows. process.env.OS_METADATA_WRITABLE = 'action'; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); - const { protocol } = tenantProtocol({ + const { protocol, rows } = tenantProtocol({ rows: [overlayRow('action', 'rc9_escalate')], artifacts: [{ type: 'action', name: 'rc9_escalate' }], }); - const result = await protocol.deleteMetaItem({ type: 'action', name: 'rc9_escalate' }); + const err: any = await protocol.deleteMetaItem({ type: 'action', name: 'rc9_escalate' }) + .then(() => null, (e: unknown) => e); - expect(result.message).toBe( - `Customization overlay deleted — action/rc9_escalate reset to artifact default. [seq=${result.seq}]`, + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(err?.message)).toContain( + "Metadata item 'action/rc9_escalate' is provided by a managed package and is sealed against removal.", ); + expect(rows).toHaveLength(1); }); afterEach(() => { diff --git a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts index 895f38694a8..a15ab136f1a 100644 --- a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts @@ -333,15 +333,14 @@ describe('#7426 — the repository refusal reaches the caller with its code', () // `error` is human language, `code` is the machine token. // [#20910, ADR-0126 §2] A type with a Regime C row (`flow`, // `action`) is refused with its row's removal sentence, naming its - // sanctioned path; every other type keeps the type-door sentence. - if (packagedBaseRegimeRow(type)) { - expect(String(err.message), type).toContain( - `Metadata item '${type}/${name}' is provided by a code package, and its packaged base is locked against removal.`, - ); - expect(String(err.message), type).toContain('docs/adr/0126-packaged-metadata-customization-model.md'); - } else { - expect(String(err.message), type).toContain('is not allowOrgOverride in the registry'); - } + // sanctioned path. [ADR-0131 D6] Every other type reads the managed + // seal; both name the managed package first. + expect(String(err.message), type).toContain( + `Metadata item '${type}/${name}' is provided by a managed package and is sealed against removal`, + ); + expect(String(err.message), type).toContain(packagedBaseRegimeRow(type) + ? 'docs/adr/0126-packaged-metadata-customization-model.md' + : 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md'); expect(String(err.message), type).not.toContain('[NOT_OVERRIDABLE]'); // A refusal that already deleted the row is a log line. expect(rows.size, type).toBe(1); diff --git a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts index 340e42b0d1c..c4a8d54bd24 100644 --- a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts +++ b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts @@ -425,7 +425,8 @@ describe('#6960 — the boundary holds: the `object` tier does NOT move', () => // The prose still has to SAY something — the envelope assertion above // is about the machine axis, and a refusal that lost its sentence would // pass it while telling the operator nothing. - expect(String(err.message), ctx).toMatch(/code package|allowOrgOverride/); + // [ADR-0131 D6] It names the managed package the item ships in. + expect(String(err.message), ctx).toMatch(/managed package/); expect(String(err.message).startsWith('['), ctx).toBe(false); }; @@ -483,7 +484,11 @@ describe('#6960 — the boundary holds: the `object` tier does NOT move', () => } }); - it('the operator hatch still opens the `object` tier — the ONE door is unchanged', async () => { + it('[ADR-0131 D6] the operator hatch no longer opens the `object` tier — a managed object is sealed', async () => { + // Was `the operator hatch still opens the object tier`: with + // `OS_METADATA_WRITABLE=object` the delete removed the row. The + // hatch opens no removal of an item a managed package ships + // now, so the answer is the shut hatch's: refused, row kept. process.env.OS_METADATA_WRITABLE = 'object'; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); @@ -494,10 +499,12 @@ describe('#6960 — the boundary holds: the `object` tier does NOT move', () => seed: [seedRow('object', 'myapp_invoice')], }); - const res = await protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' }); + const err = await protocol + .deleteMetaItem({ type: 'object', name: 'myapp_invoice' }) + .then(() => null, (e: any) => e); - expect(res.success).toBe(true); - expect(rows.size).toBe(0); + expectRefused(err, environmentId, 'object (hatch open)'); + expect(rows.size).toBe(1); }); }); } diff --git a/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts index ff2184455a7..3f066d8aade 100644 --- a/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts +++ b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts @@ -27,8 +27,9 @@ * 2. controls, unchanged: an environment-local object with a gate-refused or * a spec-refused body still answers `422 INVALID_METADATA` on both * kernels; a packaged item of a type that allows overlays - * (`allowOrgOverride: true`) and a packaged object with the - * `OS_METADATA_WRITABLE` hatch open are still judged by the gates; + * (`allowOrgOverride: true`) is still judged by the gates; [ADR-0131 D6] + * a packaged object with the `OS_METADATA_WRITABLE` hatch open is not — + * managed content is sealed, so it answers the package door like row 1; * 3. every type in `DEFAULT_METADATA_TYPE_REGISTRY`: the same packaged * publish gives the same envelope on both kernels, and every type the * door governs answers it. @@ -284,13 +285,16 @@ describe('[#22220] controls: the gates still judge what the door does not refuse } }); - it('a packaged object with the OS_METADATA_WRITABLE hatch open and a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { + it('[ADR-0131 D6] a packaged object with the OS_METADATA_WRITABLE hatch open and a body the spec parse refuses → 403 NOT_OVERRIDABLE (both kernels)', async () => { + // Was a control (422 INVALID_METADATA): the hatch carried the write past + // the package door to the gates. Managed content is sealed now, so the + // package door answers first, with the hatch open as with it shut. process.env.OS_METADATA_WRITABLE = 'object'; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); for (const [kernel, environmentId] of KERNELS) { const result = await probe(environmentId, { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')) }); - expect(result.envelope, kernel).toEqual(INVALID_METADATA); + expect(result.envelope, kernel).toEqual(NOT_OVERRIDABLE); expect(result.persistedRows, kernel).toBe(0); } }); diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index b3a565ceeb3..fdeb18ce990 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -13,7 +13,10 @@ * ONE-emitter claim: same code, status and sentence); * 2. its matrix is the metadata door's matrix, including the answers that are * deliberately `null` — a name no package ships, a Regime O overlay type, - * the #6960 delete carve-out, and the operator hatch. + * and the #6960 delete carve-out. [ADR-0131 D6] The operator hatch is no + * longer one of them: managed content is sealed, so the verdict on an item + * a managed package ships is the same with `OS_METADATA_WRITABLE` (either + * spelling) set or not. * * The registry double serves only `getArtifactItem`, which is all the verdict * reads; what it returns is what the real `SchemaRegistry` returns for an @@ -51,6 +54,7 @@ const shape = (e: any) => (e ? { code: e.code, status: e.status } : null); afterEach(() => { delete process.env.OS_METADATA_WRITABLE; + delete process.env.OBJECTSTACK_METADATA_WRITABLE; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); }); @@ -116,13 +120,38 @@ describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed .toThrow('registry unreadable'); }); - it('reads the operator hatch through the same predicate the metadata door reads', () => { - process.env.OS_METADATA_WRITABLE = 'flow'; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - const p = protocolOn(undefined); - expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })).toBeNull(); - expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })).toBeNull(); - }); + // [ADR-0131 D6] Managed content is sealed: the hatch, under either spelling + // its reader honours, opens neither verb on an item a managed package + // ships — the verdict is the one it gives with the hatch shut, sentence + // included. Every door that asks this verdict (the `/automation` doors, the + // read envelope) inherits that. + for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { + for (const environmentId of [undefined, 'env_1']) { + it(`${variable}=flow,page does not open a managed item (${environmentId ? 'environment' : 'host-config'} kernel)`, () => { + const shut = protocolOn(environmentId); + const sealed = { + save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any, + delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any, + page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any, + }; + process.env[variable] = 'flow,page'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const p = protocolOn(environmentId); + const open = { + save: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any, + delete: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any, + page: p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any, + }; + for (const verb of ['save', 'delete', 'page'] as const) { + expect(shape(open[verb]), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(open[verb].message, verb).toBe(sealed[verb].message); + } + // The #6960 carve-out and the regime-O overlay keep their `null`. + expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull(); + expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull(); + }); + } + } }); /** @@ -136,25 +165,30 @@ describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed * package's administrator cannot make. [#20910] So do `action` and * `permission`, from their own rows: an action names its activation switch and * no clone (the action-clone half is not chartered), a permission set names its - * clone and no switch. Every type with no regime keeps its sentence byte for - * byte (the control: `page`). + * clone and no switch. Every type with no regime reads the managed seal (the + * control: `page`). [ADR-0131 D6] Every one of them names the managed package + * first: the install mode is what the refusal is about. */ describe('packagedBaseRefusal — the sentence is chosen per ADR-0126 regime', () => { const CLONE = 'POST /api/v1/automation/:name/clone'; const TOGGLE = 'POST /api/v1/automation/:name/toggle'; const ADR_0126 = 'docs/adr/0126-packaged-metadata-customization-model.md'; - /** The package-less sentence every regime-less type keeps (`refusePackagedBaseOverride`). */ - const LEGACY_SAVE = (type: string, name: string) => - `Metadata item '${type}/${name}' is provided by a code package ` - + 'and the type has not opted into per-org overlay writes (allowOrgOverride=false). ' - + 'Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. ' - + 'See docs/adr/0005-metadata-customization-overlay.md.'; - /** …and its removal twin (`refusePackagedBaseRemoval`). */ - const LEGACY_DELETE = (type: string, name: string) => - `Metadata item '${type}/${name}' is provided by a code package ` - + 'and the type has not opted into per-org overlay writes. ' - + 'See docs/adr/0005-metadata-customization-overlay.md.'; + /** + * [ADR-0131 D6] The package-less sentence every regime-less type reads + * (`refusePackagedBaseOverride`): the managed seal, the registry flag that + * produced it, the hatch named only to say it does not apply, the remedy. + */ + const SEALED_SAVE = (type: string, name: string) => + `Metadata item '${type}/${name}' is provided by a managed package and is sealed against in-place edits: ` + + 'its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not open ' + + 'a managed item. Edit the source artifact and redeploy. ' + + 'See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.'; + /** …and its removal twin (`refusePackagedBaseRemoval`), which has no remedy to name. */ + const SEALED_DELETE = (type: string, name: string) => + `Metadata item '${type}/${name}' is provided by a managed package and is sealed against removal: ` + + 'its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not open ' + + 'a managed item. See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.'; for (const environmentId of [undefined, 'env_1']) { for (const operation of ['save', 'delete'] as const) { @@ -164,7 +198,7 @@ describe('packagedBaseRefusal — the sentence is chosen per ADR-0126 regime', ( .packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation }); expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); const message = String(refusal.message); - expect(message.startsWith("Metadata item 'flow/pkg_flow' is provided by a code package")).toBe(true); + expect(message.startsWith("Metadata item 'flow/pkg_flow' is provided by a managed package")).toBe(true); expect(message).toContain(CLONE); expect(message).toContain(TOGGLE); expect(message).toContain(ADR_0126); @@ -194,21 +228,23 @@ describe('packagedBaseRefusal — the sentence is chosen per ADR-0126 regime', ( expect(del?.message).toContain(TOGGLE); }); - it('control: a type with no declared regime (`page`) keeps its sentence byte for byte', () => { + it('control: a type with no declared regime (`page`) reads the managed seal', () => { const p = protocolOn('env_1'); const refusal: any = p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }); expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(refusal.message).toBe(LEGACY_SAVE('page', 'pkg_page')); + expect(refusal.message).toBe(SEALED_SAVE('page', 'pkg_page')); + // It names the hatch only to say it does not open the item — never as a remedy. + expect(refusal.message).not.toMatch(/set OS_METADATA_WRITABLE|may set OS_METADATA_WRITABLE/); }); - it('a regime-less type\'s removal sentence is unchanged too (`object`, no overlay merge at read)', () => { + it('a regime-less type\'s removal reads the managed seal too (`object`, no overlay merge at read)', () => { // `page` merges its overlay at read, so its removal is the #6960 - // carve-out and never refused; `object` is refused, and keeps its line. + // carve-out and never refused; `object` is refused, and reads the seal. const registry = { getArtifactItem: (type: string, name: string) => (type === 'object' ? shipped(name) : undefined) }; const p = new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), 'env_1'); const del: any = p.packagedBaseRefusal({ type: 'object', name: 'pkg_object', operation: 'delete' }); expect(shape(del)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(del.message).toBe(LEGACY_DELETE('object', 'pkg_object')); + expect(del.message).toBe(SEALED_DELETE('object', 'pkg_object')); }); }); @@ -221,9 +257,12 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa const ADR_0126 = 'docs/adr/0126-packaged-metadata-customization-model.md'; const OPERATOR_ONLY = 'operator-only where one install serves several organizations'; - /** The opening sentence every Regime C refusal shares — the regime's shape, not the type's. */ + /** + * The opening sentence every Regime C refusal shares — the regime's shape, + * not the type's. [ADR-0131 D6] It names the managed package. + */ const LOCKED = (type: string, name: string, operation: 'save' | 'delete') => - `Metadata item '${type}/${name}' is provided by a code package, and its packaged base is locked ` + `Metadata item '${type}/${name}' is provided by a managed package and is sealed ` + (operation === 'delete' ? 'against removal.' : 'against in-place edits.'); const refusal = ( @@ -247,18 +286,19 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa return message; }; - it('`flow` — the sentence is byte-identical to the one the row table replaced, on save and on removal', () => { - // Spelled out literally, NOT through the builder: this is the pin that - // the table refactor moved no byte of the flow sentence. + it('`flow` — the sentence, spelled out literally, on save and on removal', () => { + // Spelled out literally, NOT through the builder: the builder cannot + // move a byte of the flow sentence without this pin seeing it. + // [ADR-0131 D6] The opener names the managed package and the seal. expect(refusal('flow', 'pkg_flow', 'save').message).toBe( - "Metadata item 'flow/pkg_flow' is provided by a code package, and its packaged base is locked " + "Metadata item 'flow/pkg_flow' is provided by a managed package and is sealed " + 'against in-place edits. Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, ' + 'body {name, label}), or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; ' + 'operator-only where one install serves several organizations). ' + 'See docs/adr/0126-packaged-metadata-customization-model.md.', ); expect(refusal('flow', 'pkg_flow', 'delete').message).toBe( - "Metadata item 'flow/pkg_flow' is provided by a code package, and its packaged base is locked " + "Metadata item 'flow/pkg_flow' is provided by a managed package and is sealed " + 'against removal. Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, ' + 'body {name, label}), or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; ' + 'operator-only where one install serves several organizations). ' diff --git a/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts b/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts index 94ec93af99a..f88f28f5461 100644 --- a/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts +++ b/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts @@ -354,15 +354,20 @@ describe('[#21670] every metadata type: the read envelope agrees with its write }); describe('[#21670] controls', () => { - it('the operator hatch opens the packaged base, and the read says so — the same predicate the door reads', async () => { + it('[ADR-0131 D6] the operator hatch does not open the packaged base, and the read says so — the same predicate the door reads', async () => { + // Was `the operator hatch opens the packaged base`: the read answered + // `lock: 'none'`, editable and deletable, and the save was admitted. + // Managed content is sealed now, so with the hatch open the read and + // the door give the shut hatch's answers, and still agree. process.env.OS_METADATA_WRITABLE = 'flow,action'; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); for (const type of ['flow', 'action']) { const name = nameFor(type, 'packaged'); const { layered } = await readFlags(harness(ENV_ID), type, name); - expect(layered).toEqual({ lock: 'none', editable: true, deletable: true }); - expect(await environmentDoor(harness(ENV_ID), type, name, 'save')).toBe('admitted'); + expect(layered).toEqual({ lock: 'full', editable: false, deletable: false }); + expect(await environmentDoor(harness(ENV_ID), type, name, 'save')).toBe('refused'); + expect(await environmentDoor(harness(ENV_ID), type, name, 'delete')).toBe('refused'); } }); diff --git a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts index 0660f432947..efa3324f94f 100644 --- a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts @@ -301,41 +301,28 @@ describe('#5265 — a save receipt names what was actually written', () => { ); }); - it('an overlay of a packaged ACTION — supportsOverlay:false, and still an override', async () => { - // The mirror of the first block, and the sharpest case in this file: - // when an overlay-less type IS overridden over a packaged artifact, - // the overlay sentence is the true one. A receipt decided by - // `supportsOverlay` would get this exactly backwards; one decided by - // artifact backing gets it right. - // - // (`object` cannot stand in here: it is `allowOrgOverride: false`, so - // `SysMetadataRepository.assertAllowed` refuses an `override-artifact` - // write with `[NOT_OVERRIDABLE]` before any receipt is built. Measured, - // not assumed — this case was written against `object` first.) - // - // The specimen was `flow` until #6283 rolled its `allowOrgOverride` - // back to `false` (ADR-0005:57), then bare `action` until commit ee58392e1 - // rolled back the remaining nine unratified flags — no statically - // registered type pairs overlay-less with overridable anymore (the - // premise pin above holds the population empty). The pairing is - // still REACHABLE, through the ONE documented door that remains: - // `OS_METADATA_WRITABLE` (ADR-0005's operator escape hatch), which - // both `isOverlayAllowed` and the repository's `assertAllowed` - // consult. So this case runs `action` behind that hatch — the - // receipt wording it pins is exactly what an operator who unlocked - // a type would see. + it('an overlay of a packaged ACTION — supportsOverlay:false: no longer reachable through the hatch (ADR-0131 D6)', async () => { + // This case used to pin the OVERLAY receipt for the one pairing that + // still reached it: a packaged item of an overlay-less type, written + // behind the `OS_METADATA_WRITABLE` hatch. No statically registered + // type pairs overlay-less with overridable (the premise pin above holds + // that population empty), and managed content is sealed now, so the + // hatch no longer opens the pairing either: the save is refused with + // the seal and no receipt is built. The overlay receipt keeps its pin + // on the packaged VIEW (the regime-O overlay) above. process.env.OS_METADATA_WRITABLE = 'action'; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); const { protocol } = makeProtocol([{ type: 'action', name: 'rc5_acct' }]); - const result = await protocol.saveMetaItem({ + const err: any = await protocol.saveMetaItem({ type: 'action', name: 'rc5_acct', item: OVERLAYLESS_PROBES.action, - }); + }).then(() => null, (e: unknown) => e); - expect(result.message).toBe( - `Saved customization overlay (env-wide, state=active) — type=action, name=rc5_acct [seq=${result.seq}]`, + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(err?.message)).toContain( + "Metadata item 'action/rc5_acct' is provided by a managed package and is sealed against in-place edits.", ); }); diff --git a/packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts b/packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts index 436fb59a502..fd1320e5f59 100644 --- a/packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts +++ b/packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts @@ -124,11 +124,16 @@ describe('tenantAuthoredWriteRefusal — every flow written through an authoring } }); - it('with the operator hatch open the lock admits the write, and the body\'s stamps decide nothing', async () => { + it('[ADR-0131 D6] with the operator hatch open a shipped flow is still a locked base — the hatch opens no managed flow', async () => { + // Was `with the operator hatch open the lock admits the write`. Managed + // content is sealed: the answer is the shut hatch's, sentence and all. + const shut: any = protocolWith().protocol.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }); process.env.OS_METADATA_WRITABLE = 'flow'; ObjectStackProtocolImplementation.resetEnvWritableCache(); const { protocol } = protocolWith(); - expect(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: flowBody('pkg_flow', ASSERTED) })).toBeNull(); + const refusal: any = await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: flowBody('pkg_flow', ASSERTED) }); + expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(refusal.message).toBe(shut.message); }); it('a body claiming a package\'s provenance for a name no package ships is refused INVALID_METADATA / 422', async () => { @@ -294,14 +299,19 @@ describe('a flow saved naming, as its base, a package no installed package holds } }); - it('with the operator hatch open a shipped flow passes the lock, and a base no installed package holds is still refused', async () => { + it('[ADR-0131 D6] with the operator hatch open a shipped flow is refused as a locked base first, whatever base the save names', async () => { + // Was `with the operator hatch open a shipped flow passes the lock`: the + // hatch carried the write past the lock to the named-base rule. Managed + // content is sealed now, so the hatch-open answer is the control's above. process.env.OS_METADATA_WRITABLE = 'flow'; ObjectStackProtocolImplementation.resetEnvWritableCache(); const { protocol } = protocolWith(); const served = ARTIFACTS.get('flow')!.get('pkg_flow'); - expect(shape(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId: ORPHAN }))) - .toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 }); - expect(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId: PACKAGE_ID })).toBeNull(); + for (const packageId of [ORPHAN, PACKAGE_ID]) { + const refusal: any = await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId }); + expect(refusal?.status, packageId).toBe(403); + expect(['NOT_OVERRIDABLE', 'ITEM_LOCKED']).toContain(refusal.code); + } }); it('every other metadata type is untouched — a view naming the same base is not this rule\'s to judge', async () => { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 402b5b9f50d..6df9a994bc3 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -44,7 +44,7 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js'; import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js'; import { DraftConflictError, SysMetadataRepository, packageScopedRowWhere, type SysMetadataEngine } from './sys-metadata-repository.js'; -import { isOriginGatedType, packagedBaseRegimeSentence } from './packaged-base-regime.js'; +import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js'; import { resolveArtifactLockLayer, resolveItemLock, @@ -15974,13 +15974,34 @@ export class ObjectStackProtocolImplementation implements || this.OVERLAY_CAPABLE_TYPES.has(type); } - /** Normalize plural→singular before consulting the allow-list. */ + /** + * Does the type's REGISTRY entry open an overlay channel (`allowOrgOverride`)? + * The registry arm of {@link isOverlayAllowed}, without the hatch. + * Normalizes plural→singular before consulting the allow-list. + * + * [ADR-0131 D6] Read alone by {@link isSealedManagedItem}: whether an item a + * managed package ships may be overlaid is the registry's answer, and the + * `OS_METADATA_WRITABLE` hatch has no say in it. + */ + private static registryAllowsOverlay(type: string): boolean { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + return this.OVERLAY_ALLOWED_TYPES.has(singular) + || this.OVERLAY_ALLOWED_TYPES.has(type); + } + + /** + * The TYPE-level write channel: the registry's overlay opt-in, or the + * `OS_METADATA_WRITABLE` hatch naming the type. Normalize plural→singular + * before consulting the allow-list. + * + * [ADR-0131 D6] A type-level answer, so ⛔ never the answer for an item a + * managed package ships — that one is {@link isSealedManagedItem}'s, and the + * hatch does not reach it. What the hatch still opens through this predicate + * is the type's writes of items no managed package ships. + */ private static isOverlayAllowed(type: string): boolean { + if (this.registryAllowsOverlay(type)) return true; const singular = PLURAL_TO_SINGULAR[type] ?? type; - if (this.OVERLAY_ALLOWED_TYPES.has(singular) - || this.OVERLAY_ALLOWED_TYPES.has(type)) { - return true; - } const env = this.envWritableTypes(); return env.has(singular) || env.has(type); } @@ -16113,15 +16134,20 @@ export class ObjectStackProtocolImplementation implements * #5086 — the artifact-backed half of the same refusal: the name IS * shipped by a code package, so the honest verdict is "you may not * overlay it" rather than "you may not create it". + * + * [ADR-0131 D6] The package is a managed one and its item is sealed, so the + * sentence names the managed package and says the hatch does not open it — + * the prescription this sentence used to end with ("an operator may set + * OS_METADATA_WRITABLE") would send the operator to a door that no longer + * opens. The source remedy stays: it is where a code-only item is declared. */ private static codeOnlyOverrideError(type: string, name: string): Error { const err = new Error( - `Metadata item '${type}/${name}' is provided by a code package and its type is ` - + `code-only (allowRuntimeCreate=false, allowOrgOverride=false), so it cannot be overlaid through ` - + `the runtime metadata API on any kernel.` + `Metadata item '${type}/${name}' is provided by a managed package and its type is ` + + `code-only (allowRuntimeCreate=false, allowOrgOverride=false), so it is sealed against ` + + `runtime edits on any kernel, and OS_METADATA_WRITABLE does not open a managed item.` + ObjectStackProtocolImplementation.codeOnlySourceHint(type) - + ` An operator may set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} to grant a runtime escape hatch. ` - + `See docs/adr/0005-metadata-customization-overlay.md.` + + ` See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.` ); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; @@ -16449,6 +16475,41 @@ export class ObjectStackProtocolImplementation implements return this.isNestedArtifactField(type, name) || this.isDeclaredCodeDatasource(type, name); } + /** + * [ADR-0131 D6] Is `(type, name)` MANAGED CONTENT — sealed against every + * write onto it and every removal of it? THE predicate both package doors + * ask ({@link refusePackagedBaseOverride} and {@link refusePackagedBaseRemoval}, + * and through them {@link packagedBaseRefusal} for the `/automation` doors + * and the read envelope), and the one `saveMetaItem` / `deleteMetaItem` ask + * where the `OS_METADATA_WRITABLE` hatch would otherwise decide an item's + * fate. `SysMetadataRepository.assertAllowed` states the same rule at the + * store, where it is told the item is artifact-backed by the write intent. + * + * True when an item a managed package ships ({@link isArtifactBacked} — the + * registry's artifact-only lookup, never the body a caller sends) is of a + * type whose registry entry opens no overlay channel + * ({@link registryAllowsOverlay}). The install mode is not modelled yet, so + * every package the artifact loader registered items for is managed. + * + * What it deliberately leaves open: + * - the regime-O overlay: a type whose registry entry allows an + * environment overlay (`view`, `dashboard`, `report`, `translation`, + * `email_template`) keeps it — an overlay replaces the managed item + * beside it, it does not edit it; + * - an item no managed package ships: creating, editing and removing it is + * environment authoring, governed by `allowRuntimeCreate` and the hatch as + * before; + * - the removal of a stored overlay row that merges at read (#6960) — the + * removal door's own carve-out, which restores the managed definition. + * + * ⛔ The hatch is not consulted: `OS_METADATA_WRITABLE` unlocks a TYPE, and a + * type-level unlock says nothing about an item a managed package ships. + */ + private isSealedManagedItem(type: string, name: string): boolean { + return this.isArtifactBacked(type, name) + && !ObjectStackProtocolImplementation.registryAllowsOverlay(type); + } + /** * [#7743] Is `(field, '.')` a field a code package ships? * @@ -16644,9 +16705,9 @@ export class ObjectStackProtocolImplementation implements * "Is this artifact-backed?" ({@link isArtifactBacked}: the registry's * artifact-only lookup, which answers from the entries the artifact loader * registered under a package id — never from the body a caller sends) and - * "does the type have an overlay channel?" ({@link isOverlayAllowed}, - * `allowOrgOverride` plus the `OS_METADATA_WRITABLE` hatch) keep exactly - * one spelling. The refusal — code, status and sentence — is registered to + * "does the type have an overlay channel?" — [ADR-0131 D6] asked together + * as {@link isSealedManagedItem}, the registry's `allowOrgOverride` alone, + * never the `OS_METADATA_WRITABLE` hatch — keep exactly one spelling. The refusal — code, status and sentence — is registered to * this package in the ADR-0112 ledger, so the caller relays it verbatim * and stamps no code of its own. * @@ -16731,8 +16792,9 @@ export class ObjectStackProtocolImplementation implements * no overlay channel — {@link packagedBaseRefusal}, the verdict the * `/meta` doors and the `/automation` doors already share (`NOT_OVERRIDABLE`, * or `ITEM_LOCKED` when the write names the read-only package). It - * carries the registry's flags, the #6960 removal carve-out and the - * `OS_METADATA_WRITABLE` hatch, and answers alike on every topology. + * carries the registry's flags and the #6960 removal carve-out, and + * answers alike on every topology and — [ADR-0131 D6] managed content + * being sealed — with the `OS_METADATA_WRITABLE` hatch open or shut. * * Asked from the first limb alone, a packaged flow or action read * `lock: 'none'`, `editable: true`, `deletable: true` while every door @@ -16967,10 +17029,10 @@ export class ObjectStackProtocolImplementation implements * locked base, and the answer is {@link packagedBaseRefusal}'s — * reused, never re-implemented. That covers a round trip of a shipped * flow: its served body echoes stamps that AGREE with the set, and the - * write is still an in-place edit of a locked base. With the - * `OS_METADATA_WRITABLE` hatch open the lock admits the write, and it - * is still tenant-authored: the body's stamps decide nothing (the base - * the write names still does — see the named-base rule below). + * write is still an in-place edit of a locked base. [ADR-0131 D6] The + * `OS_METADATA_WRITABLE` hatch no longer admits it: a managed flow is + * sealed with the hatch open or shut, so the body's stamps never get + * the chance to decide anything for a name the loader's set holds. * 2. **Any other name, with a body whose stamps would classify it as * code-shipped** (`isCodeArtifactBody`, ADR-0029 D9.6), is refused * LOUDLY. The body asserts a provenance the platform never @@ -17166,13 +17228,18 @@ export class ObjectStackProtocolImplementation implements * limb's emitter, `readOnlyBaseOverrideError`, takes a Regime C type's * prescription from the same table — in that emitter, so both doors that * call it keep one sentence. + * + * [ADR-0131 D6] Managed content is sealed: the predicate is + * {@link isSealedManagedItem}, shared with {@link refusePackagedBaseRemoval}, + * and the `OS_METADATA_WRITABLE` hatch no longer takes a write past it. The + * sentence names the managed package ({@link managedItemSealedSentence}, the + * one builder the repository's type door reads too); the code and the status + * are unchanged. */ private refusePackagedBaseOverride( request: { type: string; name: string; packageId?: string | null }, ): void { - const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(request.type); - const artifactBacked = this.isArtifactBacked(request.type, request.name); - if (artifactBacked && !overlayAllowed) { + if (this.isSealedManagedItem(request.type, request.name)) { // [#8184] THE PACKAGE DOOR — the SECOND refusal point for one // condition, and the reason this card exists. // @@ -17200,34 +17267,28 @@ export class ObjectStackProtocolImplementation implements // started. // // THE LIMB ORDERING IS THE RULE, and it is the same ordering - // the repository states: BELOW every registry limb, ABOVE the - // hatch limb. + // the repository states: BELOW every registry limb, and the + // hatch is not a limb at all. // • Below the registry limb — this whole branch is guarded - // by `!overlayAllowed`, so an `allowOrgOverride` type - // never reaches the door. That is ADR-0005: an org - // overlay of a code-shipped item ALWAYS names the - // read-only package it customizes, and a door one limb - // higher would close the overlay model outright. Pinned. - // • Above the hatch limb — `isOverlayAllowed` folds - // `OS_METADATA_WRITABLE` in, so an OPEN hatch takes the - // write past this branch entirely, down to the repository - // door, which applies the same rule with `hatchOpen: - // true` and its own remedy. The hatch therefore still - // never unlocks package writability on this topology - // either (#8146 NARROW), and both directions of that - // remedy selection are pinned in - // `sys-metadata-repository.package-writability.test.ts`. - // That is also why `hatchOpen` is passed as a literal - // `false` here rather than recomputed: reaching this line - // PROVES the hatch is closed, and a recomputed value - // would be dead code dressed as a decision. + // by {@link isSealedManagedItem}, whose type half is the + // registry's `allowOrgOverride` alone, so a regime-O type + // never reaches the door. That is ADR-0005: an overlay of + // a code-shipped item ALWAYS names the read-only package + // it customizes, and a door one limb higher would close + // the overlay model outright. Pinned. + // • [ADR-0131 D6] No hatch limb. `OS_METADATA_WRITABLE` used + // to take an artifact-backed write past this branch, down + // to the repository door, where a package-less write landed + // an overlay of the managed item (#8146 had narrowed that to + // "never a NAMED read-only base"). Managed content is sealed + // now — the broad reading that comment reserved for a + // maintainer decision is the decision ADR-0131 D6 records — + // so the hatch is not consulted here, and the repository + // refuses the same write without it. // - // ⛔ NARROW, exactly as the repository is: only a write that - // NAMES a read-only base is re-coded. A package-less write - // keeps `NOT_OVERRIDABLE` verbatim. Refusing a hatch write - // that names NO read-only base (BROAD) retires the hatch's - // only documented use and needs a maintainer decision plus a - // docs/ADR change — never arrived at from here. + // The named-base limb below keeps its own code: a write that + // NAMES the read-only base answers `ITEM_LOCKED`, and a + // package-less one `NOT_OVERRIDABLE`, with or without the hatch. // // `runtime-only` needs no limb here: this branch is guarded by // `artifactBacked`, so the intent is always @@ -17238,18 +17299,13 @@ export class ObjectStackProtocolImplementation implements const namedBase = typeof request.packageId === 'string' && request.packageId.length > 0; if (namedBase && !this.isWritablePackage(request.packageId)) { throw SysMetadataRepository.readOnlyBaseOverrideError( - request.type, request.packageId as string, false, + request.type, request.packageId as string, ); } - // [#20819] The SENTENCE is chosen per ADR-0126 regime; the code, - // the status and this branch's predicate are unchanged. - const err = new Error( - packagedBaseRegimeSentence(request.type, request.name, 'save') - ?? (`Metadata item '${request.type}/${request.name}' is provided by a code package ` - + `and the type has not opted into per-org overlay writes (allowOrgOverride=false). ` - + `Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. ` - + `See docs/adr/0005-metadata-customization-overlay.md.`) - ); + // [#20819, ADR-0131 D6] The SENTENCE is chosen per ADR-0126 regime, + // and names the managed package for every other type; the code and + // the status are unchanged. + const err = new Error(managedItemSealedSentence(request.type, request.name, 'save')); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; throw err; @@ -17275,20 +17331,20 @@ export class ObjectStackProtocolImplementation implements * [#20819] Since lifted, the SENTENCE is chosen per ADR-0126 regime * ({@link packagedBaseRegimeSentence}); the predicate, the code and the * status are unchanged. + * + * [ADR-0131 D6] Managed content is sealed against removal as against edits: + * the predicate is {@link isSealedManagedItem}, the save door's, so the + * `OS_METADATA_WRITABLE` hatch no longer opens the removal either. The + * #6960 carve-out stands — removing a row that merges at read restores the + * managed definition, it does not remove it. The sentence names the managed + * package ({@link managedItemSealedSentence}). */ private refusePackagedBaseRemoval(request: { type: string; name: string }): void { - const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(request.type); - const artifactBacked = this.isArtifactBacked(request.type, request.name); const legacyOverlayRemoval = ObjectStackProtocolImplementation .mergesOverlayAtRead(request.type); - if (artifactBacked && !overlayAllowed && !legacyOverlayRemoval) { - // [#20819] Sentence per ADR-0126 regime, as in the save door. - const err = new Error( - packagedBaseRegimeSentence(request.type, request.name, 'delete') - ?? (`Metadata item '${request.type}/${request.name}' is provided by a code package ` - + `and the type has not opted into per-org overlay writes. ` - + `See docs/adr/0005-metadata-customization-overlay.md.`) - ); + if (this.isSealedManagedItem(request.type, request.name) && !legacyOverlayRemoval) { + // [#20819, ADR-0131 D6] Sentence per ADR-0126 regime, as in the save door. + const err = new Error(managedItemSealedSentence(request.type, request.name, 'delete')); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; throw err; @@ -17301,9 +17357,11 @@ export class ObjectStackProtocolImplementation implements * runtime-created free) when the item is code-defined: the package door's * own refusal, {@link packagedBaseRefusal} for `delete`, held for * {@link deleteMetaItem} to answer at its row probe. `null` for an item of - * any other type, for a runtime item of this one (nothing ships the name), - * and with `OS_METADATA_WRITABLE` open on the type — each keeps the verdict - * it had. + * any other type and for a runtime item of this one (nothing ships the + * name) — each keeps the verdict it had. [ADR-0131 D6] `OS_METADATA_WRITABLE` + * open on the type no longer lifts it: a code-defined item is sealed with + * the hatch open or shut, and the stored-row repair below is the same in + * both. * * ## The one removal it lifts, and the one it keeps * @@ -20205,9 +20263,14 @@ export class ObjectStackProtocolImplementation implements // // `isOverlayAllowed` still consults `OS_METADATA_WRITABLE`, so the // documented operator escape hatch stays the ONE door: unlocking a - // type there unlocks it here too. `deleteMetaItem` is deliberately - // NOT gated the same way — removing a code-only row that predates - // this refusal is repair, and must stay possible. + // type there unlocks it here too — [ADR-0131 D6] for a NEW item only. + // An item a managed package ships is sealed, so for it this refusal + // reads the registry alone ({@link isSealedManagedItem}'s type half): + // with the hatch open or shut it is refused here, by the same sentence, + // instead of being carried past this gate to be refused by the package + // door in another one. `deleteMetaItem` is deliberately NOT gated the + // same way — removing a code-only row that predates this refusal is + // repair, and must stay possible. // // [#6960] THAT CARVE-OUT NOW NAMES BOTH TIERS, because as written it // covered only the CODE-ONLY one (`allowRuntimeCreate: false` AND @@ -20235,10 +20298,16 @@ export class ObjectStackProtocolImplementation implements // (`supportsOverlay: false`, its overlay a contributor LAYER per // ADR-0029 D9) keeps refusing both verbs, which is D9.6's declared // cost and is pinned. See {@link deleteMetaItem}. - if (!overlayAllowed && !runtimeCreateAllowed) { - throw this.isArtifactBacked(request.type, request.name) - ? ObjectStackProtocolImplementation.codeOnlyOverrideError(request.type, request.name) - : ObjectStackProtocolImplementation.codeOnlyCreateError(request.type); + if (!runtimeCreateAllowed) { + const shipped = this.isArtifactBacked(request.type, request.name); + const channel = shipped + ? ObjectStackProtocolImplementation.registryAllowsOverlay(request.type) + : overlayAllowed; + if (!channel) { + throw shipped + ? ObjectStackProtocolImplementation.codeOnlyOverrideError(request.type, request.name) + : ObjectStackProtocolImplementation.codeOnlyCreateError(request.type); + } } // [#6190] …and the ORG dimension of the same declaration, on the tier @@ -20304,8 +20373,9 @@ export class ObjectStackProtocolImplementation implements // and none of them has to learn to defer to it. // // ⛔ NO ACCEPTANCE SET MOVES. This predicate is the repository's - // (the registry's `allowOrgOverride`, the `OS_METADATA_WRITABLE` - // hatch, a named read-only base through the one `isWritablePackage`), + // (the registry's `allowOrgOverride` — [ADR-0131 D6] never the + // `OS_METADATA_WRITABLE` hatch for an item a managed package ships — + // and a named read-only base through the one `isWritablePackage`), // and `repo.put` refuses every write it refuses, on every topology — // so a request refused here was refused before, and only which // refusal its author reads has changed: the same code and status on @@ -26721,7 +26791,16 @@ export class ObjectStackProtocolImplementation implements // folded `request.type` to singular at the top of this method, which // makes `singularTypeForRepo` a no-op re-fold — see #4432.) const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name); - const overlayAllowedForRepoDel = ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo); + // [ADR-0131 D6] For an item a managed package ships the hatch decides + // nothing, the route included: the item takes the route it takes with + // `OS_METADATA_WRITABLE` shut. Read through the hatch, a code-only + // managed item's stored residue was routed onto the repository, which + // refuses it as sealed, away from the raw-engine repair below that + // removes it with the hatch shut — so setting the hatch would have + // blocked a repair the deployment otherwise has. + const overlayAllowedForRepoDel = artifactBacked + ? ObjectStackProtocolImplementation.registryAllowsOverlay(singularTypeForRepo) + : ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo); const runtimeCreateAllowedForRepoDel = ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularTypeForRepo); const useRepoPath = overlayAllowedForRepoDel || runtimeCreateAllowedForRepoDel; diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index d93db6641a0..4c985c5cc9b 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -40,6 +40,10 @@ * - **[#8146] the hatch is type-level** — `OS_METADATA_WRITABLE` no longer * unlocks a write that NAMES a read-only base. See that block's own * docblock; it carries the ruling and the measurement NARROW rests on. + * - **[ADR-0131 D6] managed content is sealed** — and since that decision the + * hatch unlocks no `override-artifact` write at all, named base or not: the + * BROAD reading #8146 reserved for a maintainer decision is the one D6 + * records. The preservation cases NARROW kept are pinned refused below. * * ## [#8146] `OS_METADATA_WRITABLE` — from "deliberately uncovered" to pinned * @@ -371,10 +375,12 @@ describe('#7682 — the refusal discriminates on package writability', () => { * env-wide, and `{ package_id: null, organization_id: }` under an org * kernel — the documented per-org override, intact. * - * ⛔ The BROADER reading (the hatch never unlocks a write against an item a - * read-only package provides, named or not) is NOT implemented and must not - * be "completed" here: it retires the hatch's only documented use and needs a - * maintainer decision plus a docs/ADR change. + * [ADR-0131 D6] The BROADER reading (the hatch never unlocks a write against + * an item a read-only package provides, named or not) is the decision D6 + * records — managed content is sealed — and is implemented: the + * preservation cases below are pinned REFUSED, and the docs entry + * (`environment-variables.mdx`) says what the hatch no longer opens. This + * docblock used to say the broader reading needed exactly that decision. * * One measurement worth carrying, because it narrows what this card proves: * `{ package_id: , organization_id: null }` is ALSO what a genuine @@ -384,7 +390,7 @@ describe('#7682 — the refusal discriminates on package writability', () => { * is precisely the ruling's own sentence: a type-level unlock reached the * package dimension. */ - describe('#8146 — OS_METADATA_WRITABLE does not unlock a read-only package', () => { + describe('#8146 → ADR-0131 D6 — OS_METADATA_WRITABLE unlocks no write onto an item a managed package ships', () => { /** Open the hatch for `permission`, the type the QA run used. */ function openHatch(types = 'permission') { process.env.OS_METADATA_WRITABLE = types; @@ -433,6 +439,9 @@ describe('#7682 — the refusal discriminates on package writability', () => { // to grant a runtime escape hatch". Emitted while that variable IS set, // it prescribes the step the caller already took — the shape that makes // an automated client (or an AI agent) retry the same request forever. + // [ADR-0131 D6] #8146's hatch-open remedy ("retry without ?package= to + // land the overlay the hatch grants") is false too now: no package-less + // write lands one. A Regime C type is told its row's sanctioned path. openHatch(); const err = await putWith(repo, { type: 'permission', name: 'showcase_contributor', @@ -440,33 +449,33 @@ describe('#7682 — the refusal discriminates on package writability', () => { }) as { message?: string }; const message = String(err.message); - expect(message).not.toMatch(/set OS_METADATA_WRITABLE/); - // …and it states the true remedy the measurement below proves exists. - expect(message).toContain('does not apply here'); - expect(message).toContain("Retry without '?package='"); + expect(message).not.toMatch(/OS_METADATA_WRITABLE/); + expect(message).not.toContain("Retry without '?package='"); + expect(message).toContain('Clone it under a new name to customize it'); }); - // ── PRESERVATION: what NARROW deliberately keeps working ──────────── + // ── [ADR-0131 D6] SEALED: what NARROW used to keep working ────────── + // + // These three were NARROW's preservation pins ("if this ever goes red, the + // NARROW/BROAD fork goes back to the maintainer"). It went back, and the + // maintainer's decision is ADR-0131 D6: managed content is sealed. Each is + // now pinned on the far side — refused with the seal, nothing persisted — + // with the hatch open, for every base spelling NARROW admitted. - it('a package-less hatch write still lands the env-wide overlay, bound to NO package', async () => { - // THE PREMISE. If this ever goes red, NARROW is preserving nothing and - // the fork (NARROW vs BROAD) goes back to the maintainer — it is not a - // test to "repair" by relaxing it. + it('a package-less hatch write lands NO overlay: NOT_OVERRIDABLE / 403, nothing persisted', async () => { openHatch(); const err = await putWith(repo, { type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', - }); + }) as { message?: string }; - expect(err).toBeNull(); - const metaRows = Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata'); - expect(metaRows).toHaveLength(1); - expect(metaRows[0]).toMatchObject({ package_id: null, organization_id: null }); + expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(err.message)).toContain( + "Metadata item 'permission/showcase_contributor' is provided by a managed package and is sealed", + ); + expect(Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata')).toEqual([]); }); - it('a package-less hatch write under an ORG kernel lands the per-org override the docs promise', async () => { - // `environment-variables.mdx:305` — the hatch treats named types "as - // `allowOrgOverride: true` … overridden per-org". This is that sentence, - // executed: the row binds to the org and to no package. + it('a package-less hatch write under an ORG kernel lands no per-org override either', async () => { openHatch(); const orgRepo = new SysMetadataRepository({ engine: engine as never, organizationId: 'org_acme', orgLabel: 'org_acme', @@ -475,22 +484,45 @@ describe('#7682 — the refusal discriminates on package writability', () => { type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', }); - expect(err).toBeNull(); - const metaRows = Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata'); - expect(metaRows).toHaveLength(1); - expect(metaRows[0]).toMatchObject({ package_id: null, organization_id: 'org_acme' }); + expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata')).toEqual([]); }); - it('a hatch write naming a WRITABLE base still lands — the door reads writability, not the hatch', async () => { + it('a hatch write naming a WRITABLE base lands nothing — the hatch reaches no managed item on any base', async () => { openHatch(); const err = await putWith(repo, { type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', packageId: WRITABLE_PKG, }); + expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata')).toEqual([]); + }); + + it('the legacy spelling (OBJECTSTACK_METADATA_WRITABLE) opens nothing either', async () => { + // This reader honours only `OS_METADATA_WRITABLE`, so the legacy spelling + // was already shut here; pinned so the seal does not depend on that. + process.env.OBJECTSTACK_METADATA_WRITABLE = 'permission'; + resetEnvWritableMetadataTypes(); + ObjectStackProtocolImplementation.resetEnvWritableCache(); + try { + const err = await putWith(repo, { + type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', + }); + expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + } finally { + delete process.env.OBJECTSTACK_METADATA_WRITABLE; + } + }); + + it('control: the hatch keeps its TYPE-level unlock for an item no managed package ships (runtime-only)', async () => { + // `job` has no create channel: the hatch is what admits this write, and + // the seal does not reach it — a `runtime-only` intent names no managed + // item. Package-less, so no package door is asked. + openHatch('job'); + const err = await putWith(repo, { type: 'job', name: 'nightly', intent: 'runtime-only' }); expect(err).toBeNull(); - const metaRows = Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata'); - expect(metaRows[0]).toMatchObject({ package_id: WRITABLE_PKG }); + expect(Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata')).toHaveLength(1); }); it('the ADR-0005 overlay is untouched: a registry-allowed type still overlays a read-only package', async () => { @@ -508,25 +540,25 @@ describe('#7682 — the refusal discriminates on package writability', () => { expect(metaRows[0]).toMatchObject({ package_id: READ_ONLY_PKG }); }); - it('with the hatch CLOSED the refusal still offers it — the prescription is chosen, not deleted', async () => { - // The other side of the false-prescription pin: opening the hatch (on a - // package-less write) remains a real answer, so the sentence must - // survive when the hatch is not already set. - // - // [#20910] MOVED from `permission` to `page`, a type with no ADR-0126 - // regime row. ADR-0126 §2 is why the two sentences now differ: a Regime C - // type's packaged base is "refused loudly at the write door, the refusal - // naming the sanctioned path", and for `flow` / `action` / `permission` - // that path is the clone or the switch, never the hatch (the pin beside - // this one). For every type with no regime row the hatch is still the - // answer this limb offers. Measured to reach the SAME limb: `ITEM_LOCKED`, - // `lockSource: 'package'`, the named base echoed back. - const err = await putWith(repo, { - type: 'page', name: 'crm_landing', - intent: 'override-artifact', packageId: READ_ONLY_PKG, - }) as { message?: string }; - expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403, lockSource: 'package', packageId: READ_ONLY_PKG }); - expect(String(err.message)).toContain('set OS_METADATA_WRITABLE=page'); + it('[ADR-0131 D6] with the hatch CLOSED the refusal no longer offers it — opening it lands nothing', async () => { + // This was the other side of the false-prescription pin: under NARROW, + // opening the hatch on a package-less write was a real answer, so the + // sentence offered it. Managed content is sealed now, so that offer is + // the false prescription: the sentence names the seal and says the hatch + // does not open a managed item, hatch shut or open, and keeps the source + // remedy. A type with no regime row (`page`); the envelope is unchanged. + for (const hatch of [undefined, 'page']) { + if (hatch) openHatch(hatch); + const err = await putWith(repo, { + type: 'page', name: 'crm_landing', + intent: 'override-artifact', packageId: READ_ONLY_PKG, + }) as { message?: string }; + expect(err, String(hatch)).toMatchObject({ code: 'ITEM_LOCKED', status: 403, lockSource: 'package', packageId: READ_ONLY_PKG }); + const message = String(err.message); + expect(message, String(hatch)).not.toMatch(/set OS_METADATA_WRITABLE/); + expect(message, String(hatch)).toContain('OS_METADATA_WRITABLE does not open a managed item'); + expect(message, String(hatch)).toContain('Edit the source artifact and redeploy.'); + } }); it('[ADR-0126 §2] …while a Regime C type with the hatch CLOSED is told its sanctioned path, not the hatch', async () => { @@ -664,10 +696,10 @@ describe('#7682 / #8146 — through saveMetaItem on the host-config topology', ( expect(metaRows).toEqual([]); }, 30_000); - it('[#8146] the same hatch write WITHOUT ?package= still lands, bound to no package', async () => { - // The preservation half, end to end. NARROW refuses the named base and - // nothing else; this is the behaviour `environment-variables.mdx` promises - // and the reason the broad reading was not taken. + it('[ADR-0131 D6] the same hatch write WITHOUT ?package= is refused too: NOT_OVERRIDABLE / 403, nothing persisted', async () => { + // Was NARROW's preservation half, end to end ("still lands, bound to no + // package"). Managed content is sealed: the protocol's package door + // refuses it on this topology before the repository is reached. const { engine, protocol } = boot(); process.env.OS_METADATA_WRITABLE = 'permission'; resetEnvWritableMetadataTypes(); @@ -677,11 +709,10 @@ describe('#7682 / #8146 — through saveMetaItem on the host-config topology', ( .saveMetaItem({ type: 'permission', name: 'showcase_contributor', item: permissionBody }) .then(() => null, (e: unknown) => e); - expect(err).toBeNull(); + expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); const metaRows = Array.from((engine as unknown as { rows: Map }).rows.values()) .filter((r) => r.__table === 'sys_metadata'); - expect(metaRows).toHaveLength(1); - expect(metaRows[0]).toMatchObject({ package_id: null, organization_id: null }); + expect(metaRows).toEqual([]); }, 30_000); }); @@ -710,12 +741,11 @@ describe('#7682 / #8146 — through saveMetaItem on the host-config topology', ( * two kernels' answers to EACH OTHER (`the two kernels agree`) rather than * asserting a literal twice. * - * **The limb ordering is the rule, here too.** `isOverlayAllowed` folds the - * registry flag AND the `OS_METADATA_WRITABLE` hatch into one predicate, so - * this branch is reached only with BOTH closed — the door is therefore below - * every registry limb (an ADR-0005 overlay never reaches it, pinned) and the - * hatch-open direction is delivered by the repository door downstream, which - * this block measures rather than assumes. + * **The limb ordering is the rule, here too.** The door is below every + * registry limb (an ADR-0005 overlay never reaches it, pinned). [ADR-0131 D6] + * The hatch is no longer a limb: the protocol's package door reads the + * registry flag alone (`isSealedManagedItem`), so a hatch-open write of a + * managed item meets this door and the repository's refuses it the same way. */ describe('#8184 — the scoped kernel answers the same code as the host-config kernel', () => { /** @@ -834,28 +864,26 @@ describe('#8184 — the scoped kernel answers the same code as the host-config k // ── the hatchOpen remedy selection, BOTH directions, on this kernel ──── - it('with the hatch CLOSED the refusal offers it — the prescription is chosen, not deleted', async () => { - const err = await save(boot('env_alpha').protocol, { + it('[ADR-0131 D6] the refusal never offers the hatch — hatch shut or open, the same sentence', async () => { + // Was two pins: hatch CLOSED, the refusal offered `set OS_METADATA_WRITABLE`; + // hatch OPEN, it said the hatch "does not apply here" and to retry + // package-less. Both remedies are false under the seal (no package-less + // write lands an overlay of a managed item), so the sentence no longer + // depends on the hatch at all. + const shut = await save(boot('env_alpha').protocol, { + type: 'object', name: 'showcase_task', item: objectBody, packageId: READ_ONLY_PKG, + }) as { code?: string; status?: number; message?: string }; + openHatch('object'); + const open = await save(boot('env_alpha').protocol, { type: 'object', name: 'showcase_task', item: objectBody, packageId: READ_ONLY_PKG, - }) as { message?: string }; - expect(String(err.message)).toContain('set OS_METADATA_WRITABLE=object'); - }, 30_000); - - it('with the hatch OPEN the refusal does NOT prescribe the step already taken', async () => { - // The false-prescription trap, on the topology this card is about. The - // hatch-open write does not reach the protocol branch at all — an open - // hatch makes `isOverlayAllowed` true — so this measures that the write - // falls through to the repository door and is answered there with the - // SAME code and the hatch-aware remedy. That is why the protocol site - // passes `hatchOpen: false` rather than recomputing it. - openHatch('permission'); - const err = await save(boot('env_alpha').protocol, { - type: 'permission', name: 'showcase_contributor', item: permissionBody, packageId: READ_ONLY_PKG, }) as { code?: string; status?: number; message?: string }; - expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); - expect(String(err.message)).not.toContain('set OS_METADATA_WRITABLE=permission'); - expect(String(err.message)).toContain('does not apply here'); + for (const err of [shut, open]) { + expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + expect(String(err.message)).not.toMatch(/set OS_METADATA_WRITABLE/); + expect(String(err.message)).toContain('OS_METADATA_WRITABLE does not open a managed item'); + } + expect(open.message).toBe(shut.message); }, 30_000); // ── PRESERVATION: the load-bearing pins ─────────────────────────────── @@ -873,47 +901,24 @@ describe('#8184 — the scoped kernel answers the same code as the host-config k expect(metaRowsOf(engine)[0]).toMatchObject({ package_id: READ_ONLY_PKG }); }, 30_000); - it('a package-less hatch write still lands the env-wide overlay, bound to NO package', async () => { - // THE PREMISE of NARROW, on this kernel. Red here means NARROW preserves - // nothing and the NARROW/BROAD fork goes back to the maintainer — not a - // test to "repair" by relaxing it. - openHatch('permission'); - const { engine, protocol } = boot('env_alpha'); - const err = await save(protocol, { - type: 'permission', name: 'showcase_contributor', item: permissionBody, - }); - - expect(err).toBeNull(); - const rows = metaRowsOf(engine); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ package_id: null, organization_id: null }); - }, 30_000); - - it('a package-less hatch write under an ORG kernel lands the per-org override the docs promise', async () => { - openHatch('permission'); - const { engine, protocol } = boot('env_alpha'); - const err = await save(protocol, { - type: 'permission', name: 'showcase_contributor', - item: permissionBody, organizationId: 'org_acme', - }); - - expect(err).toBeNull(); - const rows = metaRowsOf(engine); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ package_id: null, organization_id: 'org_acme' }); - }, 30_000); - - it('a hatch write naming a WRITABLE base still lands — the door reads writability, not the hatch', async () => { - openHatch('permission'); - const { engine, protocol } = boot('env_alpha'); - const err = await save(protocol, { - type: 'permission', name: 'showcase_contributor', - item: permissionBody, packageId: WRITABLE_PKG, - }); + // [ADR-0131 D6] NARROW's three preservation pins, on this kernel, now on the + // far side: managed content is sealed, so none of them lands a row. + for (const [label, extra] of [ + ['a package-less hatch write', {}], + ['a package-less hatch write under an ORG kernel', { organizationId: 'org_acme' }], + ['a hatch write naming a WRITABLE base', { packageId: WRITABLE_PKG }], + ] as const) { + it(`[ADR-0131 D6] ${label} lands no overlay of a managed item: NOT_OVERRIDABLE / 403`, async () => { + openHatch('permission'); + const { engine, protocol } = boot('env_alpha'); + const err = await save(protocol, { + type: 'permission', name: 'showcase_contributor', item: permissionBody, ...extra, + }); - expect(err).toBeNull(); - expect(metaRowsOf(engine)[0]).toMatchObject({ package_id: WRITABLE_PKG }); - }, 30_000); + expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(metaRowsOf(engine)).toEqual([]); + }, 30_000); + } }); /** @@ -1282,7 +1287,7 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, const err = await putWith(repo, { type, name: `pkg_${type}`, intent: 'override-artifact' }) as any; expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect(String(err.message)).toBe( - `Metadata item '${type}/pkg_${type}' is provided by a code package, and its packaged base is locked ` + `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` + `against in-place edits. ${paths} ${ADR_0126}`, ); expect(Array.from(engine.rows.values())).toEqual([]); @@ -1298,29 +1303,51 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, .then(() => null, (e: unknown) => e) as any; expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect(String(err.message)).toBe( - `Metadata item '${type}/pkg_${type}' is provided by a code package, and its packaged base is locked ` + `Metadata item '${type}/pkg_${type}' is provided by a managed package and is sealed ` + `against removal. ${paths} ${ADR_0126}`, ); }); } - it('type door, control: a type with no regime row keeps the overlay-allowed list and the hatch, byte for byte', async () => { + it('[ADR-0131 D6] type door, control: a type with no regime row reads the managed seal — the protocol\'s own sentence', async () => { + // Was the overlay-allowed list and "Set OS_METADATA_WRITABLE to enable + // additional types at runtime", byte for byte. That prescription is false + // for an `override-artifact` write now, so this door throws the ONE + // sentence the protocol's package door throws for the same item. const err = await putWith(repo, { type: 'object', name: 'showcase_task', intent: 'override-artifact' }) as any; expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect(String(err.message)).toBe( - "'object' is not allowOrgOverride in the registry. " - + 'Overlay-allowed: view, dashboard, report, translation, email_template. ' - + 'Set OS_METADATA_WRITABLE to enable additional types at runtime.', + "Metadata item 'object/showcase_task' is provided by a managed package and is sealed against in-place edits: " + + 'its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not open ' + + 'a managed item. Edit the source artifact and redeploy. ' + + 'See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.', ); }); - it('type door: the hatch still opens a Regime C type exactly as before (NARROW untouched)', async () => { + it('[ADR-0131 D6] type door: the hatch no longer opens a Regime C type — the seal, with the row\'s path', async () => { + // Was `the hatch still opens a Regime C type exactly as before (NARROW + // untouched)`: the write landed a package-less row. Now refused with the + // same sentence the shut hatch gets, nothing persisted. + const shut = await putWith(repo, { type: 'action', name: 'pkg_action', intent: 'override-artifact' }) as any; process.env.OS_METADATA_WRITABLE = 'action'; resetEnvWritableMetadataTypes(); - const err = await putWith(repo, { type: 'action', name: 'pkg_action', intent: 'override-artifact' }); - expect(err).toBeNull(); - const metaRows = Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata'); - expect(metaRows[0]).toMatchObject({ package_id: null, organization_id: null }); + const open = await putWith(repo, { type: 'action', name: 'pkg_action', intent: 'override-artifact' }) as any; + expect(open).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(open.message).toBe(shut.message); + expect(Array.from(engine.rows.values()).filter((r) => r.__table === 'sys_metadata')).toEqual([]); + }); + + it('[ADR-0131 D6] type door, delete: the hatch no longer opens the removal of a managed flow', async () => { + process.env.OS_METADATA_WRITABLE = 'flow'; + resetEnvWritableMetadataTypes(); + const err = await repo + .delete({ org: 'env', type: 'flow', name: 'pkg_flow' }, { parentVersion: 'sha256:whatever', actor: null, intent: 'override-artifact' }) + .then(() => null, (e: unknown) => e) as any; + expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(err.message)).toBe( + "Metadata item 'flow/pkg_flow' is provided by a managed package and is sealed " + + `against removal. ${FLOW_PATHS} ${ADR_0126}`, + ); }); // ── the named-base ITEM_LOCKED limb (a read-only base named, hatch closed) ── @@ -1339,19 +1366,16 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, }); } - it('named base, hatch OPEN: a Regime C type keeps the hatch-open remedy byte for byte', async () => { + it('[ADR-0131 D6] named base, hatch OPEN: a Regime C type is told its row\'s path, as with the hatch closed', async () => { + // Was `keeps the hatch-open remedy byte for byte` ("retry without + // ?package= to land the overlay the hatch grants") — false under the seal. process.env.OS_METADATA_WRITABLE = 'flow'; resetEnvWritableMetadataTypes(); const err = await putWith(repo, { type: 'flow', name: 'pkg_flow', intent: 'override-artifact', packageId: READ_ONLY_PKG }) as any; expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403, lockSource: 'package', packageId: READ_ONLY_PKG }); expect(String(err.message)).toBe( - `Cannot overlay 'flow' in package '${READ_ONLY_PKG}': that package is read-only ` - + '(provided by code or an installed app) and the type has no per-org overlay channel ' - + '(allowOrgOverride=false), so this item is locked against runtime edits. ' - + 'OS_METADATA_WRITABLE=flow is set, and it does not apply here: the hatch unlocks the ' - + "metadata TYPE (treating it as allowOrgOverride), never a package's writability. " - + "Retry without '?package=' to land the env-wide / per-org overlay the hatch does grant, " - + 'or edit the source artifact and redeploy. See docs/adr/0010-metadata-protection-model.md.', + `Cannot overlay 'flow' in package '${READ_ONLY_PKG}': that package is read-only, and its packaged base ` + + `is locked against in-place edits. ${FLOW_PATHS} ${ADR_0126}`, ); }); @@ -1363,7 +1387,7 @@ describe('[#20910] the repository doors name a Regime C type\'s sanctioned path, const longPkg = `com.example.${'x'.repeat(52)}`; expect(longPkg).toHaveLength(64); for (const [type] of PATHS) { - const message = String(SysMetadataRepository.readOnlyBaseOverrideError(type, longPkg, false).message); + const message = String(SysMetadataRepository.readOnlyBaseOverrideError(type, longPkg).message); expect(message.length, type).toBeLessThan(500); expect(message.endsWith(ADR_0126), type).toBe(true); } diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index cfe84ec1ede..690b710cc41 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -87,7 +87,7 @@ import type { IObjectQLEngine } from '@objectstack/core'; // lifecycle gate read, so a third read-only signal added there reaches this // door too (that shared-rule argument is the module's whole reason to exist). import { isWritablePackage } from './package-writability.js'; -import { isOriginGatedType, packagedBaseRegimePrescription, packagedBaseRegimeSentence } from './packaged-base-regime.js'; +import { isOriginGatedType, managedItemSealedSentence, packagedBaseRegimePrescription } from './packaged-base-regime.js'; /** * Canonicalise a driver-materialised timestamp into the ISO-8601 string the @@ -1693,9 +1693,12 @@ export class SysMetadataRepository implements MetadataRepository { * at `(type, name)`. In that case we accept types with * `allowRuntimeCreate: true`, even when `allowOrgOverride` is false. * - * The env-var escape hatch (`OS_METADATA_WRITABLE`) still - * applies to BOTH intents, so operators can opt into artifact - * overrides at runtime for emergency fixes. + * The env-var escape hatch (`OS_METADATA_WRITABLE`) applies to the + * `runtime-only` intent ONLY. [ADR-0131 D6] Managed content is sealed: an + * `override-artifact` write is a write onto an item a managed package + * ships, and no door — this one included — admits it on the hatch. It used + * to apply to both intents, "so operators can opt into artifact overrides + * at runtime for emergency fixes"; that use is the one D6 withdraws. * * ## [#7682] The package door — which fact the refusal reports * @@ -1727,17 +1730,17 @@ export class SysMetadataRepository implements MetadataRepository { * package CHANGELOG's "deliberately does not unlock the org dimension". * A type-level unlock says nothing about the PACKAGE dimension, so a * hatch write that NAMES a read-only base is refused here. - * - **How far that reaches — NARROW, and the measurement it rests on.** The - * door reads the base the caller NAMED, so a package-LESS hatch write is - * untouched and still lands the overlay the documentation promises. That - * is measured, not assumed: on this topology a package-less hatch write - * lands `{ package_id: null, organization_id: null }` env-wide and - * `{ package_id: null, organization_id: }` under an org kernel — - * both pinned in `sys-metadata-repository.package-writability.test.ts`. - * The BROADER reading (the hatch never unlocks a write against an item a - * read-only package provides, named or not) would retire the hatch's only - * documented use and is deliberately NOT implemented: it needs a - * maintainer decision plus a docs/ADR change, not a code edit. + * - **How far that reaches — NARROW, then BROAD by decision.** The door + * reads the base the caller NAMED, so under #8146 a package-LESS hatch + * write still landed an overlay of the artifact (env-wide, or per-org + * under an org kernel). That comment reserved the BROADER reading — the + * hatch never unlocks a write against an item a read-only package + * provides, named or not — for "a maintainer decision plus a docs/ADR + * change". [ADR-0131 D6] That decision is recorded: managed content is + * sealed, and the hatch opens the `runtime-only` intent only (see the + * hatch limb below). The docs entry (`environment-variables.mdx`) says + * so, and `sys-metadata-repository.package-writability.test.ts` pins the + * package-less hatch write refused where it used to land. * - **Beyond the hatch it still refuses nothing new.** For every other * intent this remains the code-selection for writes ALREADY refused; the * difference is which true fact the refusal reports. Widening it into a @@ -1820,49 +1823,46 @@ export class SysMetadataRepository implements MetadataRepository { if (namedBase && !isWritablePackage(this.engine, packageId)) { throw intent === 'runtime-only' ? SysMetadataRepository.readOnlyBaseCreateError(type, packageId as string, hatchOpen) - : SysMetadataRepository.readOnlyBaseOverrideError(type, packageId as string, hatchOpen); + : SysMetadataRepository.readOnlyBaseOverrideError(type, packageId as string); } // [#8146] The hatch unlocks the TYPE — for a write that named no base, or - // named a writable one. It never reaches the package dimension. - if (hatchOpen) return; - - // [#20910, ADR-0126 §2] An item a code package ships, of a type with a - // Regime C row, is refused with the row-built sentence — the SAME one the - // protocol's package door answers on an environment-scoped kernel, which - // never reaches here for this write — naming the type's sanctioned path. A - // locked Regime C base is customized by its clone or switched off, never by - // opening this hatch, so the list-and-hatch sentence below would prescribe - // the wrong door. Reached only with the hatch CLOSED (it returned above). - // Every type with no regime row keeps that sentence, byte for byte. + // named a writable one. It never reaches the package dimension. [ADR-0131 + // D6] Nor does it reach an item a managed package ships: it opens the + // `runtime-only` intent alone, a write of an item no managed package + // ships. The `override-artifact` intent falls through to the seal below + // with the hatch open or shut. + if (hatchOpen && intent === 'runtime-only') return; + + // [#20910, ADR-0126 §2, ADR-0131 D6] An item a managed package ships, of a + // type with no overlay channel, is SEALED, and is refused with the ONE + // sentence the protocol's package doors throw (`managedItemSealedSentence`) + // — a Regime C type's row-built sentence naming its sanctioned path, and + // the managed seal itself for every other type. Reached with the hatch + // open or shut: the hatch decides nothing about such an item, so no + // sentence here prescribes it. if (intent !== 'runtime-only') { - const regimeSentence = packagedBaseRegimeSentence(singular, ref.name, operation); - if (regimeSentence !== undefined) { - const err: any = new Error(regimeSentence); - err.code = 'NOT_OVERRIDABLE'; - err.status = 403; - throw err; - } + const err: any = new Error(managedItemSealedSentence(singular, ref.name, operation)); + err.code = 'NOT_OVERRIDABLE'; + err.status = 403; + throw err; } + // `runtime-only` with no create channel: the hatch is the one door that + // opens it (a write of an item no managed package ships), so it is named. const allowed = [ ...OVERLAY_ALLOWED_TYPES, ...envWritableMetadataTypes(), ]; - const code = intent === 'runtime-only' ? 'NOT_CREATABLE' : 'NOT_OVERRIDABLE'; - const detail = intent === 'runtime-only' - ? `'${type}' has neither allowOrgOverride nor allowRuntimeCreate in the registry. ` - : `'${type}' is not allowOrgOverride in the registry. `; - // ⛔ No `[${code}]` opener: the token below IS the `code` this throw - // declares three lines down, so a bracketed restatement duplicates onto the - // prose axis a fact the envelope already carries — and, spelled by - // interpolation, it is invisible to every grep for a literal tag. + // ⛔ No `[NOT_CREATABLE]` opener: that token IS the `code` this throw + // declares below, so a bracketed restatement duplicates onto the prose + // axis a fact the envelope already carries. const err: any = new Error( - `${detail}` + + `'${type}' has neither allowOrgOverride nor allowRuntimeCreate in the registry. ` + `Overlay-allowed: ${Array.from(new Set(allowed)).join(', ') || '(none)'}. ` + `Set OS_METADATA_WRITABLE to enable additional types at runtime.`, ); - err.code = code; + err.code = 'NOT_CREATABLE'; err.status = 403; throw err; } @@ -1931,14 +1931,15 @@ export class SysMetadataRepository implements MetadataRepository { * * `ITEM_LOCKED` rather than `WRITABLE_PACKAGE_REQUIRED`: switching packages * cannot help — the artifact is code-shipped wherever the caller points — - * so the refusal states the lock and prescribes what DOES move it. [#8146] - * That prescription is now chosen by `hatchOpen`, because the two cases have - * genuinely different remedies: with the hatch CLOSED, opening it (on a - * package-less write) is one of the real answers; with it already OPEN, this - * door is refusing *despite* it — by ruling — and repeating "set - * OS_METADATA_WRITABLE" would be a false prescription of exactly the kind - * PR #8185's patch round rejected. Same code and status either way: the - * condition is one condition, and only the remedy differs. + * so the refusal states the lock and prescribes what DOES move it. + * [ADR-0131 D6] The prescription no longer depends on the + * `OS_METADATA_WRITABLE` hatch. #8146 chose it by whether the hatch was open + * (shut: "set the hatch and write package-less"; open: "retry package-less + * to land the overlay the hatch grants"), and both remedies are false now: + * the item is managed content, sealed whichever way the write is spelled. + * So the sentence names the seal, says the hatch does not open it (the + * operator who set it is told why it did nothing, rather than setting it + * again), and names the remedy that remains. Same code and status as ever. * `lockSource: 'package'` is ADR-0010's own reserved value for a lock the * PACKAGE layer asserts, which is what makes this distinguishable from the * item-level `_lock` refusal (`assertLockAllowsWrite`) that carries a `lock` @@ -1955,38 +1956,23 @@ export class SysMetadataRepository implements MetadataRepository { * a copy in `protocol.ts` would drift from this one the first time either * moves. ⛔ Do not re-privatise without deleting that call site. */ - static readOnlyBaseOverrideError(type: string, packageId: string, hatchOpen = false): Error { + static readOnlyBaseOverrideError(type: string, packageId: string): Error { const singular = PLURAL_TO_SINGULAR[type] ?? type; - // [#20910, ADR-0126 §2] With the hatch CLOSED, a type with a Regime C row is - // told its row's sanctioned path — not the hatch: a locked Regime C base is - // customized by its clone or switched off, and the hatch is not that type's - // sanctioned path. The opener is shortened to the lock, so the prescription - // and its ADR-0126 citation arrive whole inside the REST door's - // 500-character bound (pinned with a long package id). The hatch-OPEN - // remedy, the code, the status, `lockSource`, `packageId` and `docs` are - // the same for every type; every type with no regime row keeps both - // remedies below, byte for byte. - const regimePrescription = hatchOpen ? undefined : packagedBaseRegimePrescription(singular); + // [#20910, ADR-0126 §2] A type with a Regime C row is told its row's + // sanctioned path — never the hatch: a locked Regime C base is customized + // by its clone or switched off. The opener is shortened to the lock, so the + // prescription and its ADR-0126 citation arrive whole inside the REST + // door's 500-character bound (pinned with a long package id). The code, + // the status, `lockSource`, `packageId` and `docs` are the same for every + // type. + const regimePrescription = packagedBaseRegimePrescription(singular); const err: any = new Error(regimePrescription !== undefined ? `Cannot overlay '${type}' in package '${packageId}': that package is read-only, and its packaged base ` + `is locked against in-place edits. ${regimePrescription}` - : `Cannot overlay '${type}' in package '${packageId}': that package is read-only ` - + `(provided by code or an installed app) and the type has no per-org overlay channel ` - + `(allowOrgOverride=false), so this item is locked against runtime edits. ` - // [#8146] The prescription is chosen by whether the hatch is ALREADY - // open, because "set OS_METADATA_WRITABLE" is FALSE once it is set — - // this door refuses with it set, by ruling. A refusal that prescribes - // the step the caller already took is what makes an automated client - // (and an AI agent) retry the same request forever. - + (hatchOpen - ? `OS_METADATA_WRITABLE=${singular} is set, and it does not apply here: the hatch unlocks the ` - + `metadata TYPE (treating it as allowOrgOverride), never a package's writability. ` - + `Retry without '?package=' to land the env-wide / per-org overlay the hatch does grant, ` - + `or edit the source artifact and redeploy.` - : `Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE=${singular} ` - + `to grant a runtime escape hatch on this TYPE (it does not unlock package writability, ` - + `so pair it with a package-less write).`) - + ` See docs/adr/0010-metadata-protection-model.md.`); + : `Cannot overlay '${type}' in package '${packageId}': that package is read-only (a managed package) ` + + `and the type has no environment overlay channel (allowOrgOverride=false), so this item is sealed ` + + `against runtime edits, and OS_METADATA_WRITABLE does not open a managed item. ` + + `Edit the source artifact and redeploy. See docs/adr/0010-metadata-protection-model.md.`); err.code = 'ITEM_LOCKED'; err.status = 403; err.lockSource = 'package'; diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index b6fe15d7d36..1c7108d7529 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -663,8 +663,9 @@ describe('#6563 — revertCommit restores a runtime-created `object`', () => { }); // The token is asserted on `code` just above; the message carries the // human sentence and no longer restates it. + // [ADR-0131 D6] The repository's type door throws the managed seal. expect(res.failed[0].error).toContain( - `'object' is not allowOrgOverride in the registry.`, + `Metadata item 'object/myapp_invoice' is provided by a managed package and is sealed against in-place edits`, ); // Refused means refused: the edit the commit made is still the live body. expect(storedFields(rows, 'myapp_invoice').fields).toContain('due_date'); @@ -861,8 +862,9 @@ describe('#6620 — revertCommit soft-removes a runtime-CREATED `object`', () => }); // The token is asserted on `code` just above; the message carries the // human sentence and no longer restates it. + // [ADR-0131 D6] The repository's type door throws the managed seal. expect(res.failed[0].error).toContain( - `'object' is not allowOrgOverride in the registry.`, + `Metadata item 'object/myapp_invoice' is provided by a managed package and is sealed against removal`, ); // Refused means refused: the artifact-backed row is still there. expect(storedRows(rows, 'myapp_invoice')).toHaveLength(1); diff --git a/packages/objectql/src/protocol-destructive.test.ts b/packages/objectql/src/protocol-destructive.test.ts index de6c275233f..79fd3c509e5 100644 --- a/packages/objectql/src/protocol-destructive.test.ts +++ b/packages/objectql/src/protocol-destructive.test.ts @@ -19,9 +19,14 @@ describe('ObjectStackProtocolImplementation - destructive change detection', () beforeEach(() => { registry = new SchemaRegistry({ multiTenant: false }); + // [ADR-0131 D6] A TENANT-AUTHORED object (`_provenance: 'org'`, the + // stamp the server writes on every object authored in this + // environment): its in-place write is an ordinary environment edit, + // which is the population this detector guards. registry.registerObject({ name: 'account', label: 'Account', + _provenance: 'org', fields: { name: { name: 'name', type: 'text' }, amount: { name: 'amount', type: 'number' }, @@ -39,16 +44,12 @@ describe('ObjectStackProtocolImplementation - destructive change detection', () count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue([]), }; - // `account` is a packaged object (`'pkg'`), and `object` has no - // per-org overlay channel, so the package door refuses its in-place - // write before the destructive check on every kernel topology. This - // suite used to reach the check by leaving `environmentId` unset, - // which skipped that door; the door is now asked on every topology, - // so the suite opens the documented operator hatch instead — the one - // route by which a packaged object's write reaches the check. - process.env.OS_METADATA_WRITABLE = 'object'; - // Two memoised readers of the same env var — the protocol's gate and - // the repository's `assertAllowed`. Both are reset. + // This suite used to register `account` as a PACKAGED object and open + // the `OS_METADATA_WRITABLE=object` hatch, the one route by which a + // packaged object's in-place write reached this check. [ADR-0131 D6] + // Managed content is sealed now — no door writes a packaged object in + // place, hatch or not — so the object above is tenant-authored, the + // one population whose in-place write still reaches the detector. ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); protocol = new ObjectStackProtocolImplementation(mockEngine); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index da8e2f9aa7b..878a49716d4 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SchemaRegistry } from './registry.js'; /** @@ -419,32 +419,22 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // this name, the row genuinely customizes it and the historical // sentence is the true one. Pinned so the split cannot collapse // into "never say overlay". - // A clone: `registerItem` stamps `_packageId` onto the item it is - // given, and `sampleApp` is shared by every case in this file. - registry.registerItem('app', { ...sampleApp }, 'name', 'com.acme.showcase'); - - // Commit ee58392e1 rolled `app`'s `allowOrgOverride` back to `false` - // (ADR-0005 table: ❌ for page/app/action), so overriding this - // packaged app needs the one documented door that remains — the - // `OS_METADATA_WRITABLE` operator escape hatch. The receipt - // wording pinned here is what an operator behind it would see. - process.env.OS_METADATA_WRITABLE = 'app'; - (ObjectStackProtocolImplementation as any).resetEnvWritableCache(); - resetEnvWritableMetadataTypes(); - try { - const result = await protocol.saveMetaItem({ - type: 'app', name: 'test_app', item: sampleApp, organizationId: 'org_alpha', - }); + // [ADR-0131 D6] The specimen is a packaged VIEW, the regime-O type + // whose overlay the registry allows. It was a packaged `app` behind + // the `OS_METADATA_WRITABLE=app` hatch (commit ee58392e1 rolled + // `app`'s `allowOrgOverride` back to `false`), but managed content + // is sealed now and the hatch no longer opens that overlay. + const sampleView = { name: 'test_grid', label: 'Test Grid', object: 'account', list: { columns: ['name'] } }; + registry.registerItem('view', { ...sampleView }, 'name', 'com.acme.showcase'); + + const result = await protocol.saveMetaItem({ + type: 'view', name: 'test_grid', item: sampleView, organizationId: 'org_alpha', + }); - expect(result.success).toBe(true); - expect(result.message).toMatch( - /^Saved customization overlay \(org=org_alpha, state=active\) — type=app, name=test_app \[seq=\d+\]$/, - ); - } finally { - delete process.env.OS_METADATA_WRITABLE; - (ObjectStackProtocolImplementation as any).resetEnvWritableCache(); - resetEnvWritableMetadataTypes(); - } + expect(result.success).toBe(true); + expect(result.message).toMatch( + /^Saved customization overlay \(org=org_alpha, state=active\) — type=view, name=test_grid \[seq=\d+\]$/, + ); }); it('should fail-fast when DB findOne is unavailable (ADR-0005)', async () => { diff --git a/packages/objectql/src/protocol-object-overlay-layer.test.ts b/packages/objectql/src/protocol-object-overlay-layer.test.ts index 715048b53d4..f54c5a8f7ae 100644 --- a/packages/objectql/src/protocol-object-overlay-layer.test.ts +++ b/packages/objectql/src/protocol-object-overlay-layer.test.ts @@ -49,6 +49,15 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; * of a PACKAGED object can no longer be re-saved or reset without the * documented operator hatch either. That is not softened here — it is pinned, * because a fixture encoding the old leniency would be encoding the defect. + * + * ## [ADR-0131 D6] …and not WITH the hatch either, any more + * + * D9.6 named `OS_METADATA_WRITABLE=object` the one door "for the life of the + * customization". Managed content is sealed now: the hatch opens no write onto, + * and no removal of, an object a managed package ships. The D9.7 subtraction + * cases below that drove the delete under the hatch are pinned REFUSED, the + * layer and the data plane intact; the subtraction itself stays reachable where + * the row leaves by another route (a replica converging on a removal). */ const APP_PKG = 'app.myapp'; @@ -210,7 +219,11 @@ const fieldNames = (registry: SchemaRegistry, name: string) => const storedRows = (rows: Map, name: string) => Array.from(rows.values()).filter((r) => r.name === name); -/** [ADR-0005 / D9.6] The documented operator hatch — the ONE door, for the LIFE of the customization. */ +/** + * [ADR-0005 / D9.6] The documented operator hatch — D9.6's "one door, for the + * LIFE of the customization". [ADR-0131 D6] It opens no door onto a managed + * object now; the cases that open it pin exactly that. + */ function withObjectWritable(run: () => T): T { const previous = process.env.OS_METADATA_WRITABLE; process.env.OS_METADATA_WRITABLE = 'object'; @@ -356,56 +369,43 @@ describe('ADR-0029 D9.6 — the declared contract, enforced consistently', () => describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is retired', () => { /** - * THE RESTORATION THAT IS NOT A RE-REGISTRATION. Under the hatch — the one - * door D9.6 names, which now has to stay open for the life of the - * customization — the delete removes the tenant's LAYER and the packaged - * owner, which was never destroyed, is served again. Pre-D9 the same delete - * emptied `objectContributors` and 404'd the data plane. + * [ADR-0131 D6] Was THE RESTORATION THAT IS NOT A RE-REGISTRATION, driven + * under the hatch — "the one door D9.6 names, which now has to stay open + * for the life of the customization". Managed content is sealed: the hatch + * no longer opens the removal, so the delete is refused, the row and the + * layer stay, and the data plane keeps dispatching through the refusal. */ - it('removes the overlay layer and serves the packaged owner again, data plane up throughout', async () => { + it('[ADR-0131 D6] the hatch no longer opens the subtraction: refused, the layer stays, data plane up throughout', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); const { protocol, registry, rows, dataRows } = await bootWithPackage(seed); - // The data plane works before the delete, so "works after" cannot be - // green for the empty reason. expect((await protocol.createData({ object: 'myapp_invoice', data: { name: 'INV-1' } })).id).toBeTruthy(); - const res = await withObjectWritable(() => - protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' })); - - expect(res.success).toBe(true); - expect(storedRows(rows, 'myapp_invoice')).toHaveLength(0); + for (const type of ['object', 'objects']) { + const err = await withObjectWritable(() => + protocol.deleteMetaItem({ type, name: 'myapp_invoice' }).then(() => null, (e: any) => e)); + expect(err, type).toBeInstanceOf(Error); + expect(err.code, type).toBe('NOT_OVERRIDABLE'); + expect(err.status, type).toBe(403); + } - // The layer is gone; the package's own definition is back, in full. - expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); + expect(storedRows(rows, 'myapp_invoice')).toHaveLength(1); + expect(kinds(registry, 'myapp_invoice')).toEqual(['own', 'overlay']); expect(ownerPackageId(registry, 'myapp_invoice')).toBe(APP_PKG); - expect(fieldNames(registry, 'myapp_invoice')).toContain('packaged_only'); - expect(fieldNames(registry, 'myapp_invoice')).not.toContain('overlay_only'); - expect((registry.getObject('myapp_invoice') as any)._provenance).toBe('package'); - - // …and CRUD never stopped dispatching. expect((await protocol.createData({ object: 'myapp_invoice', data: { name: 'INV-2' } })).id).toBeTruthy(); expect(dataRows).toHaveLength(2); }); - it('the plural `objects` spelling reaches the same subtraction', async () => { - const seed = await persistOverlayRow('myapp_invoice', APP_PKG); - const { protocol, registry } = await bootWithPackage(seed); - - await withObjectWritable(() => protocol.deleteMetaItem({ type: 'objects', name: 'myapp_invoice' })); - - expect(kinds(registry, 'myapp_invoice')).toEqual(['own']); - expect(fieldNames(registry, 'myapp_invoice')).toContain('packaged_only'); - }); - /** * A CONTROL-PLANE kernel skips `deleteMetaItem`'s two-tier authorization * entirely (`environmentId === undefined`) — but not the repository's * `assertAllowed`, which is topology-independent and refuses an * `override-artifact` delete of a type without `allowOrgOverride`. Pinned * so "the tenant gate is skipped" is never mistaken for "ungated". + * [ADR-0131 D6] With the hatch open too: the repository no longer lets the + * hatch reach an `override-artifact` delete (it used to subtract here). */ - it('a control-plane kernel refuses at the repository, and subtracts under the hatch', async () => { + it('a control-plane kernel refuses at the repository, with the hatch shut and open', async () => { const seed = await persistOverlayRow('myapp_invoice', APP_PKG); const a = await bootWithPackage(seed, { controlPlane: true }); const refused = await a.protocol @@ -416,8 +416,13 @@ describe('ADR-0029 D9.7 — the delete is a SUBTRACTION, and #7012\'s guard is r expect(refused.status).toBe(403); const b = await bootWithPackage(seed, { controlPlane: true }); - await withObjectWritable(() => b.protocol.deleteMetaItem({ type: 'object', name: 'myapp_invoice' })); - expect(kinds(b.registry, 'myapp_invoice')).toEqual(['own']); + const refusedOpen = await withObjectWritable(() => b.protocol + .deleteMetaItem({ type: 'object', name: 'myapp_invoice' }) + .then(() => null, (e: any) => e)); + expect(refusedOpen).toBeInstanceOf(Error); + expect(refusedOpen.code).toBe('NOT_OVERRIDABLE'); + expect(refusedOpen.status).toBe(403); + expect(kinds(b.registry, 'myapp_invoice')).toEqual(['own', 'overlay']); }); /** @@ -535,15 +540,17 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a const seed = await persistOverlayRow('myapp_invoice', APP_PKG); const { protocol, registry, rows } = await bootWithPackage(seed); + // [ADR-0131 D6] Under the hatch this write used to reach the D9.9 + // package check (`OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422). Managed + // content is sealed now, so the package door answers first, hatch open + // or not; the boot-side half of D9.9 is the next case. const err = await withObjectWritable(() => protocol.saveMetaItem({ type: 'object', name: 'myapp_invoice', packageId: OTHER_PKG, item: overlayBody('myapp_invoice'), }).then(() => null, (e: any) => e)); expect(err).toBeInstanceOf(Error); - expect(err.code).toBe('OBJECT_OVERLAY_PACKAGE_MISMATCH'); - expect(err.status).toBe(422); - expect(String(err.message)).toContain(OTHER_PKG); - expect(String(err.message)).toContain(APP_PKG); + expect(err.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); // No success receipt, and no row for the mis-bound package. expect(storedRows(rows, 'myapp_invoice').filter((r) => r.package_id === OTHER_PKG)).toHaveLength(0); diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index 848e4e5f8a2..14e1c86da7c 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -194,7 +194,10 @@ function findByName(items: any[], name: string): any { // amendment table says ❌ for `page`/`app`/`action`), so overriding the // PACKAGED app these suites are built around now needs the ONE documented // door that remains: the `OS_METADATA_WRITABLE` operator escape hatch, which -// both write gates consult. The machinery pinned here — envelope-preserving +// both write gates consult. [ADR-0131 D6] Since the seal, the hatch opens no +// write onto an app a managed package SHIPS: the overlay row these suites need +// is written before the package's artifact arrives (`overlayRowThenLockedArtifact`), +// and a write onto the shipped app is refused by the package door. The machinery pinned here — envelope-preserving // hydration, shadow healing, lock-vs-shadow ordering — is exactly what an // operator who unlocked a type would exercise, so the cases run behind the // hatch rather than re-specimening to `view` and losing the app-switcher @@ -246,9 +249,11 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art it('GET list while the overlay row exists: overlay content wins, artifact envelope wins', async () => { await overlayRowThenLockedArtifact('full'); - // The lock now holds on this kernel too: a further PUT is refused. + // A further PUT is refused. [ADR-0131 D6] By the package door now, ahead + // of the item lock: the hatch opens no write onto an app a managed + // package ships, so the seal answers before the `_lock` gate is asked. await expect(protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody, label: 'Again' } })) - .rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); const res = await protocol.getMetaItems({ type: 'app' }); const setup = findByName((res as any).items, 'setup'); @@ -318,8 +323,9 @@ describe('registry shadow — scoped-kernel lock enforcement is shadow-immune', // Same commit ee58392e1 door as above: with `app` no longer allowOrgOverride, the // save would 403 NOT_OVERRIDABLE at the type gate and never reach the // L3 lock this case exists to prove is shadow-immune. Behind the hatch - // the type gate passes and the LOCK is what refuses — the ordering the - // assertion (`ITEM_LOCKED`, not `NOT_OVERRIDABLE`) pins. + // the type gate used to pass and the LOCK refused. [ADR-0131 D6] The hatch + // no longer opens a managed app, so the save answers the package door + // (`NOT_OVERRIDABLE`); the delete still reaches the lock (`ITEM_LOCKED`). beforeEach(unlockAppOverridesViaEnvHatch); afterEach(resetEnvHatch); @@ -357,10 +363,17 @@ describe('registry shadow — scoped-kernel lock enforcement is shadow-immune', mockEngine, undefined, 'env_prod', ); + // [ADR-0131 D6] The SAVE is refused by the package door now, ahead of + // the lock: the hatch opens no write onto an app a managed package + // ships. That door is shadow-immune for the same reason the lock is — + // `isArtifactBacked` reads the composite-key artifact, never the + // plain-key shadow — so the save still refuses; the lock's own + // shadow-immunity stays pinned on the delete, which the #6960 + // carve-out (an `app` overlay merges at read) carries to the lock. await expect(protocol.saveMetaItem({ type: 'app', name: 'setup', organizationId: 'org_a', item: { ...overlayBody }, - })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); await expect(protocol.deleteMetaItem({ type: 'app', name: 'setup', organizationId: 'org_a', diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts index e59d3686e4c..e2b8e2cffb6 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts @@ -19,14 +19,18 @@ * source — see `vitest.config.ts`) over a minimal fake engine, on the * host-config topology (`environmentId` undefined — the flagship showcase's * own assembly, the one whose `saveMetaItem` runs the authoring gate ahead of - * every persistence path). The refusal cases assert the lock's ERROR CLASS - * IDENTITY (`instanceof PackagedPermissionSetLockedError`), not only the - * `code`/`status` envelope: `NOT_OVERRIDABLE`/403 is shared with the ADR-0005 - * tier gate by design, so the class is the only fingerprint that proves WHICH - * layer answered. With the hatch CLOSED the protocol's own package door - * answers ahead of the seam on every topology, so that one case asserts the - * class is NOT the lock's. And every refusal case asserts the ROW COUNT — the defect - * this card measured was a write that landed, so "threw" alone is half a pin. + * every persistence path). The refusal cases assert WHICH layer answered by + * ERROR CLASS IDENTITY, not only by the `code`/`status` envelope: + * `NOT_OVERRIDABLE`/403 is shared by the lock, the ADR-0005 tier gate and the + * protocol's package door, so the class is the only fingerprint. Since + * ADR-0131 D6 (#15206 S2) the protocol's own package door seals an item a + * managed package ships with the hatch OPEN as well as CLOSED, and it answers + * ahead of the seam on every topology. So every save of the package-declared + * name asserts the class is NOT the lock's (`PackagedPermissionSetLockedError`): + * the lock is still registered on the seam (`wired`), and the preservation + * cases below still pass through it. And every refusal case asserts the ROW + * COUNT — the defect this card measured was a write that landed, so "threw" + * alone is half a pin. * * ## What is deliberately NOT re-pinned here (Prime Directive #8) * @@ -196,18 +200,19 @@ describe('#11843 — the lock answers at the metadata door', () => { // ── the inversion of the measured defect ───────────────────────────────── - it('hatch OPEN: a package-less save targeting a package-declared set is refused by the LOCK, and no row lands', async () => { + it('hatch OPEN: a package-less save targeting a package-declared set is refused by the protocol package door, and no row lands', async () => { const { engine, protocol, wired } = boot(); expect(wired).toBe(true); openHatch(); const err = await save(protocol, { type: 'permission', name: PACKAGED_SET, item: body(PACKAGED_SET) }); - // Class identity is the layer fingerprint — the ADR-0005 tier gate shares - // this code and status, but only the lock constructs this class. - expect(err).toBeInstanceOf(PackagedPermissionSetLockedError); + // [ADR-0131 D6] The hatch no longer opens an item a managed package ships: + // the protocol's package door answers ahead of the seam, as with the hatch + // closed, so the lock is not reached. Class identity is the layer + // fingerprint — only the lock constructs this class. + expect(err).not.toBeInstanceOf(PackagedPermissionSetLockedError); expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect((err as Error).message).toContain(PKG); // The defect was a write that LANDED — the throw alone is half the pin. expect(metaRowsOf(engine)).toEqual([]); }, 30_000); @@ -226,7 +231,7 @@ describe('#11843 — the lock answers at the metadata door', () => { expect(metaRowsOf(engine)).toEqual([]); }, 30_000); - it('hatch OPEN: a DRAFT save of the packaged name is refused too — the seam gates both minting paths', async () => { + it('hatch OPEN: a DRAFT save of the packaged name is refused by the protocol package door too, and no row lands', async () => { const { engine, protocol } = boot(); openHatch(); @@ -234,7 +239,7 @@ describe('#11843 — the lock answers at the metadata door', () => { type: 'permission', name: PACKAGED_SET, item: body(PACKAGED_SET), mode: 'draft', }); - expect(err).toBeInstanceOf(PackagedPermissionSetLockedError); + expect(err).not.toBeInstanceOf(PackagedPermissionSetLockedError); expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect(metaRowsOf(engine)).toEqual([]); }, 30_000); diff --git a/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts b/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts new file mode 100644 index 00000000000..91d584029be --- /dev/null +++ b/packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D6] Managed content is sealed — on a BOOTED app, through the REST +// door a browser crosses, with the `OS_METADATA_WRITABLE` hatch open. +// +// The hatch used to open a write onto, and a removal of, an item a managed +// package ships, for every type it named. Measured on the CRM app before this +// change, with `OS_METADATA_WRITABLE=flow,object,permission,position`: +// +// PUT /meta/flow/crm_convert_lead_wizard 200, an overlay row +// PUT /meta/object/crm_lead (a field relabelled) 200, an overlay row +// PUT /meta/position/sales_rep 200, an overlay row +// PUT /automation/crm_convert_lead_wizard 200, re-registered +// DELETE /meta/flow|object/... over a stored row 200, the row removed +// DELETE /meta/object/crm_lead?dropStorage=true 200 — and the managed +// object was gone from the data plane: POST /data/crm_lead answered +// 404 OBJECT_NOT_FOUND, GET /data/crm_lead 500 DATABASE_ERROR. +// +// (`PUT /meta/permission/crm_sales_user` was already refused, by +// plugin-security's packaged-permission-set lock; the protocol's package door +// now answers it first.) Each of those is now the answer the shut hatch gives: +// 403 `NOT_OVERRIDABLE`, nothing written, nothing removed. +// +// The controls — what the seal leaves open, measured on the same boot: +// - a regime-O overlay: a packaged VIEW is overlaid and its overlay removed; +// - a NEW flow, through `PUT /meta/flow/:name` and through `POST /automation` +// (the card's positive control: creating a new flow in Studio works); +// - the switch: `POST /automation/:name/toggle` turns the managed flow off and +// on (this `single`-posture boot is inert to the operator wall, whose +// walled-posture pins are `runtime/src/domains/activation-gate*.test.ts`); +// - the clone: a sibling under a NEW name, carrying no linkage key; +// - the #6960 repair: removing a stored overlay row of a type whose loader +// merges it at read (`permission`, `position`) restores the package's +// definition and stays allowed. +// +// The "stored row" a removal needs is the row a pre-seal hatch write left: it +// is written through the protocol's own repository with the `runtime-only` +// intent — the one write the seal does not judge — standing in for that +// legacy row. The dispatcher transport is pinned in +// `runtime/src/meta-managed-content-seal.test.ts` (its `/meta` door serves no +// DELETE), the legacy spelling `OBJECTSTACK_METADATA_WRITABLE` there and in +// `rest/src/rest-meta-managed-seal-hatch.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const HATCH = 'flow,object,field,permission,position'; +const FLOW = 'crm_convert_lead_wizard'; +const OBJECT = 'crm_lead'; +const PERMISSION = 'crm_sales_user'; +const POSITION = 'sales_rep'; +const VIEW = 'crm_opportunity.all'; + +type Json = Record; + +/** The served document's authorable keys — the envelope and every `_` stamp dropped. */ +const ENVELOPE_KEYS = new Set([ + 'editable', 'deletable', 'lock', 'lockReason', 'lockSource', 'lockDocsUrl', 'provenance', + 'packageId', 'packageVersion', 'resettable', 'version', 'etag', +]); +const authorable = (doc: Json | undefined): Json => + Object.fromEntries(Object.entries(doc ?? {}).filter(([k]) => !k.startsWith('_') && !ENVELOPE_KEYS.has(k))); + +describe('[ADR-0131 D6] managed content is sealed — the hatch opens nothing a managed package ships (CRM, REST)', () => { + let stack: VerifyStack; + let token: string; + let priorWritable: string | undefined; + + beforeAll(async () => { + // Set before the boot: both readers memoise the variable on first use. + priorWritable = process.env.OS_METADATA_WRITABLE; + process.env.OS_METADATA_WRITABLE = HATCH; + stack = await bootStack(crmStack as never, { automation: true } as never); + token = await stack.signIn(); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorWritable === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = priorWritable; + }); + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const text = await res.text(); + let json: Json = {}; + try { json = JSON.parse(text) as Json; } catch { json = { raw: text }; } + return { status: res.status, json, code: (json.code ?? json.error?.code) as unknown, text }; + }; + const served = async (type: string, name: string): Promise => { + const r = await call('GET', `/meta/${type}/${name}`); + expect(r.status, `GET ${type}/${name}: ${r.text}`).toBe(200); + return (r.json.data?.item ?? r.json.data ?? r.json.item ?? r.json) as Json; + }; + const storedRows = async (type: string, name: string) => { + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as { + find(object: string, options?: unknown): Promise; + }; + return ql.find('sys_metadata', { + where: { type, name }, + context: { isSystem: true, positions: [], permissions: [] }, + }); + }; + /** A pre-seal overlay row, written through the protocol's own repository (see the header). */ + const legacyRow = async (type: string, name: string, body: Json) => { + const protocol = (await stack.kernel.getServiceAsync('protocol')) as any; + const repo = protocol.getOverlayRepo(null); + const ref = { type, name, org: 'env' }; + const current = await repo.get(ref, { state: 'active' }); + await repo.put(ref, body, { + parentVersion: current?.hash ?? null, + actor: null, + source: 'dogfood.legacy-hatch-row', + intent: 'runtime-only', + state: 'active', + }); + }; + const expectSealed = (r: { status: number; code: unknown; text: string }, what: string) => { + expect(r.status, `${what}: ${r.text}`).toBe(403); + expect(r.code, what).toBe('NOT_OVERRIDABLE'); + expect(r.text, what).toContain('managed package'); + }; + + it('premise: the hatch is open — the type listing still advertises it for flow', async () => { + // The listing's type-level flag is the hatch's own reading; this pins + // that the boot really runs with it open, so every refusal below is + // the seal and not a shut hatch. + const r = await call('GET', '/meta/types'); + const entries = (r.json.data?.entries ?? r.json.entries ?? []) as Json[]; + const flow = entries.find((e) => e?.type === 'flow'); + expect(flow).toMatchObject({ allowOrgOverride: true, overrideSource: 'env' }); + }); + + it('PUT of a managed flow, object (a field relabelled), field, permission set and position — 403, no row', async () => { + const flow = authorable(await served('flow', FLOW)); + const object = authorable(await served('object', OBJECT)); + const relabelled = { ...object, fields: { ...object.fields, name: { ...object.fields?.name, label: 'Renamed' } } }; + const permission = authorable(await served('permission', PERMISSION)); + const position = authorable(await served('position', POSITION)); + + for (const [type, name, body] of [ + ['flow', FLOW, { ...flow, label: 'Edited in place' }], + ['object', OBJECT, relabelled], + ['field', `${OBJECT}.name`, { name: 'name', type: 'text', label: 'Renamed' }], + ['permission', PERMISSION, { ...permission, label: 'Edited in place' }], + ['position', POSITION, { ...position, label: 'Edited in place' }], + ] as const) { + expectSealed(await call('PUT', `/meta/${type}/${name}`, body), `PUT ${type}/${name}`); + expect(await storedRows(type, name), `${type}/${name}`).toEqual([]); + } + }); + + it('PUT /automation/:name of the managed flow — 403, the engine keeps the shipped definition', async () => { + const flow = authorable(await served('flow', FLOW)); + expectSealed(await call('PUT', `/automation/${FLOW}`, { ...flow, label: 'Edited in place' }), 'PUT /automation'); + expect((await served('flow', FLOW)).label).toBe(flow.label); + }); + + it('DELETE of a managed flow and object over a stored row — 403, the row stays', async () => { + await legacyRow('flow', FLOW, { ...authorable(await served('flow', FLOW)), label: 'Pre-seal overlay' }); + const object = authorable(await served('object', OBJECT)); + await legacyRow('object', OBJECT, { ...object, label: 'Pre-seal overlay' }); + for (const [type, name] of [['flow', FLOW], ['object', OBJECT]] as const) { + expectSealed(await call('DELETE', `/meta/${type}/${name}`), `DELETE ${type}/${name}`); + expect(await storedRows(type, name), `${type}/${name}`).toHaveLength(1); + } + }); + + it('DELETE ?dropStorage=true of the managed object — 403, and its data plane stays up', async () => { + // The removal the hatch used to open dropped the managed object's + // table and took it off the data plane (see the header). + const before = await call('POST', `/data/${OBJECT}`, { name: 'Seal probe — before' }); + expect(before.status, before.text).toBeLessThan(300); + expectSealed(await call('DELETE', `/meta/object/${OBJECT}?dropStorage=true`), 'DELETE ?dropStorage=true'); + const after = await call('POST', `/data/${OBJECT}`, { name: 'Seal probe — after' }); + expect(after.status, after.text).toBeLessThan(300); + const list = await call('GET', `/data/${OBJECT}`); + expect(list.status, list.text).toBe(200); + }); + + it('control (#6960): removing a stored overlay row of a permission set and a position restores the package\'s definition', async () => { + for (const [type, name] of [['permission', PERMISSION], ['position', POSITION]] as const) { + const shipped = authorable(await served(type, name)); + await legacyRow(type, name, { ...shipped, label: 'Pre-seal overlay' }); + const r = await call('DELETE', `/meta/${type}/${name}`); + expect(r.status, `${type}/${name}: ${r.text}`).toBe(200); + expect(r.text, `${type}/${name}`).toContain('reset to artifact default'); + expect(await storedRows(type, name), `${type}/${name}`).toEqual([]); + } + }); + + it('control (regime O): a packaged VIEW is overlaid, and its overlay removed', async () => { + const view = authorable(await served('view', VIEW)); + const put = await call('PUT', `/meta/view/${VIEW}`, { ...view, label: 'My Opportunities' }); + expect(put.status, put.text).toBe(200); + const del = await call('DELETE', `/meta/view/${VIEW}`); + expect(del.status, del.text).toBe(200); + expect(del.text).toContain('reset to artifact default'); + }); + + it('control (the card\'s positive control): a NEW flow is created through /meta and through /automation', async () => { + const body = (name: string) => ({ + name, + label: 'Seal control', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }); + const meta = await call('PUT', '/meta/flow/seal_control_meta', body('seal_control_meta')); + expect(meta.status, meta.text).toBe(200); + const automation = await call('POST', '/automation', body('seal_control_automation')); + expect(automation.status, automation.text).toBe(200); + expect((await served('flow', 'seal_control_meta')).name).toBe('seal_control_meta'); + }); + + it('control (ADR-0126 §7.2): the switch turns the managed flow off and on', async () => { + const off = await call('POST', `/automation/${FLOW}/toggle`, { enabled: false }); + expect(off.status, off.text).toBe(200); + const on = await call('POST', `/automation/${FLOW}/toggle`, { enabled: true }); + expect(on.status, on.text).toBe(200); + }); + + it('control (ADR-0126 §7.1): the clone is a sibling under a NEW name with no linkage key', async () => { + const r = await call('POST', `/automation/${FLOW}/clone`, { name: 'seal_control_clone', label: 'Seal clone' }); + expect(r.status, r.text).toBe(200); + const flow = (r.json.data?.flow ?? {}) as Json; + expect(flow.name).toBe('seal_control_clone'); + expect(Object.keys(flow).filter((k) => /^_|cloned|source|base|linkage|origin/i.test(k))).toEqual([]); + }); +}); diff --git a/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts index 1b516473953..e96ca48bf29 100644 --- a/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts +++ b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts @@ -34,7 +34,9 @@ // an older release left it, and the restart is refused; // - CONTROL: stored definitions under two built-in position names — the // platform's own declaration sits beside them (ADR-0005), and the restart -// boots; +// boots. The save door refuses that write now, with the hatch set too +// (ADR-0131 D6: managed content is sealed), so the rows are written at the +// driver the way an older release left them; // - CONTROL: a package whose names the environment does not hold boots on a // database whose environment holds others, and restarts. // @@ -115,7 +117,9 @@ describe('ADR-0048 N.3: a package-held position or permission-set name the envir }); // The built-in control saves under two built-in position names, which the - // platform's package registers, so the save needs the documented hatch. The + // platform's package registers. Before ADR-0131 D6 that save needed the + // documented hatch; the hatch no longer opens an item a managed package + // ships, and the control pins that it stays shut with the hatch set. The // protocol reads `OS_METADATA_WRITABLE` ONCE per process and memoises it, so // it is set for the whole file, before the first boot: set inside the one // case, a save in an earlier case would already have memoised it closed. No @@ -204,9 +208,22 @@ describe('ADR-0048 N.3: a package-held position or permission-set name the envir const db = databaseFile(); stack = await bootStack(baseApp, { databaseFile: db }); const saveToken = await stack.signIn(); + // [ADR-0131 D6] The save door refuses the write, with the hatch set too... for (const name of ['org_admin', 'everyone']) { - const saved = await stack.apiAs(saveToken, 'PUT', `/meta/position/${name}`, { name, label: `Repurposed ${name}` }); - expect(saved.status, JSON.stringify(await saved.clone().json().catch(() => ({})))).toBe(200); + const refused = await stack.apiAs(saveToken, 'PUT', `/meta/position/${name}`, { name, label: `Repurposed ${name}` }); + const envelope: any = await refused.clone().json().catch(() => ({})); + expect(refused.status, JSON.stringify(envelope)).toBe(403); + expect(envelope?.code ?? envelope?.error?.code).toBe('NOT_OVERRIDABLE'); + } + // ...so the rows are written the way an older release left them: active, + // environment-wide, bound to no package. + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const now = new Date().toISOString(); + for (const name of ['org_admin', 'everyone']) { + await ql.insert('sys_metadata', { + type: 'position', name, organization_id: null, package_id: null, state: 'active', version: 1, checksum: null, + created_at: now, updated_at: now, metadata: JSON.stringify({ name, label: `Repurposed ${name}` }), + }, SYS); } await stack.stop(); stack = undefined; diff --git a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts index 2d159ac9963..92eb9225e15 100644 --- a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts @@ -92,6 +92,18 @@ // reads AGREE and that they do not serve the catalog string, both of which // remain true — what changed is the value they agree ON, which it deliberately // never named. Its prose is updated where the fix falsified it. +// +// ══════════════════════════════════════════════════════════════════════════ +// [ADR-0131 D6] MANAGED CONTENT IS SEALED — WHERE THE RENAME NOW COMES FROM +// ══════════════════════════════════════════════════════════════════════════ +// +// The rename used to be the Studio round-trip itself, behind the +// `OS_METADATA_WRITABLE=object` hatch. The hatch opens no write onto an object +// a managed package ships any more, so that round-trip is now pinned REFUSED +// (403 `NOT_OVERRIDABLE`, nothing stored). The rename the two read cases need +// is the one a deployment still carries: a row stored before the seal, read by +// a COLD boot over the same database. The read path is unchanged by the seal, +// so those two cases still measure what they always measured. import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -111,42 +123,91 @@ const CATALOG_LABEL = 'Account'; const labelOf = (item: unknown): unknown => (item as { label?: unknown } | null | undefined)?.label; +/** The platform's own write context, for the one row this file stores by hand. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +/** The tenant's rename, as a row stored before the seal carries it. */ +const RENAMED_LABEL = 'Customer'; + describe('dogfood: the object-extension fold and the i18n catalog disagree on scalars (#8037)', () => { let stack: VerifyStack; let token: string; let tempDir: string; let priorWritable: string | undefined; + let artifactPath: string; + let databaseFile: string; + + /** + * Boots from a COMPILED ARTIFACT, whose `objects` and `objectExtensions` + * are separate collections — the deployment shape, and the only one on + * which this family of defects is observable at all. Over a database FILE, + * so a second boot is a real cold start on the rows the first one left. + */ + const boot = () => bootStack(showcaseStack, { + databaseFile, + extraPlugins: [ + new MetadataPlugin({ + rootDir: tempDir, + watch: false, + artifactWatch: false, + registerSystemObjects: false, + artifactSource: { mode: 'local-file', path: artifactPath }, + }), + ], + }); beforeAll(async () => { - // The last case performs a tenant customisation of an `object`, which is - // not overlay-writable by default (`NOT_OVERRIDABLE`). This is the same - // switch a deployment flips to let Studio customise object metadata. + // [ADR-0131 D6] Open, so the refusal case below measures the seal with + // the switch a deployment used to flip to let Studio customise object + // metadata — the switch no longer reaches an object a package ships. priorWritable = process.env.OS_METADATA_WRITABLE; process.env.OS_METADATA_WRITABLE = 'object'; tempDir = mkdtempSync(join(tmpdir(), 'os-8037-scalar-')); - const artifactPath = join(tempDir, 'objectstack.json'); + artifactPath = join(tempDir, 'objectstack.json'); + databaseFile = join(tempDir, 'showcase.db'); // The real `objectstack build` lowering, for the same reason #7556's // dogfood file uses it: `JSON.stringify(stack)` drops callables silently. writeBuildShapedArtifact(showcaseStack as unknown as Record, artifactPath); - // Boots from a COMPILED ARTIFACT, whose `objects` and `objectExtensions` - // are separate collections — the deployment shape, and the only one on - // which this family of defects is observable at all. - stack = await bootStack(showcaseStack, { - extraPlugins: [ - new MetadataPlugin({ - rootDir: tempDir, - watch: false, - artifactWatch: false, - registerSystemObjects: false, - artifactSource: { mode: 'local-file', path: artifactPath }, - }), - ], - }); + stack = await boot(); token = await stack.signIn(); }, 180_000); + /** + * The tenant's rename as a deployment carries it after the seal: a row a + * Studio save stored BEFORE it (the authored document, renamed), read by a + * cold boot over the same file. Done once; both read cases ask for it, so + * neither depends on running after the other. + */ + let renamed: Promise | undefined; + const withPreSealRename = (): Promise => (renamed ??= (async () => { + const declared = ((showcaseStack as any).objects as Array>) + .find((o) => o?.name === 'showcase_account'); + expect(declared, 'the showcase still declares showcase_account').toBeDefined(); + const body = JSON.parse(JSON.stringify({ ...declared, label: RENAMED_LABEL })); + for (const key of Object.keys(body)) if (key.startsWith('_')) delete body[key]; + const now = new Date().toISOString(); + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as { + insert(object: string, data: Record, options?: unknown): Promise; + }; + await ql.insert('sys_metadata', { + type: 'object', + name: 'showcase_account', + organization_id: null, + package_id: null, + state: 'active', + version: 1, + checksum: null, + created_at: now, + updated_at: now, + metadata: JSON.stringify(body), + }, { context: SYSTEM_CTX }); + await stack.stop(); + stack = await boot(); + token = await stack.signIn(); + })()); + afterAll(async () => { await stack?.stop(); if (tempDir) rmSync(tempDir, { recursive: true, force: true }); @@ -219,25 +280,39 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc expect(labelOf(singleBody?.item)).toBe(EXTENSION_LABEL); }); - it('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => { - // The ordinary Studio round-trip: GET the served document, rename it, - // PUT it back. The write path persists the request body verbatim - // (ADR-0005 §Validation), so this is exactly what an admin's save stores. + it('[ADR-0131 D6] the Studio rename round-trip of a PACKAGED object is refused, hatch open — nothing is stored', async () => { + // The round-trip the rename cases below used to perform: GET the served + // document, rename it, PUT it back. Managed content is sealed, so the + // package door refuses it with the hatch open, and the reads keep + // serving the folded (extension) label. const before: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); const put = await stack.apiAs(token, 'PUT', '/meta/object/showcase_account', { - ...(before?.item ?? {}), label: 'Customer', + ...(before?.item ?? {}), label: RENAMED_LABEL, }); - expect(put.status).toBeLessThan(400); + const putBody: any = await put.json().catch(() => ({})); + expect(put.status, JSON.stringify(putBody)).toBe(403); + expect(putBody?.code ?? putBody?.error?.code).toBe('NOT_OVERRIDABLE'); + + const layered: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true')).json(); + expect(layered?.overlay ?? null).toBeNull(); + expect(labelOf(layered?.effective)).toBe(EXTENSION_LABEL); + expect(await listedLabel()).toBe(EXTENSION_LABEL); + }); + + it('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => { + // [ADR-0131 D6] The rename is the row a pre-seal Studio save stored — + // see `withPreSealRename` — read by a cold boot. + await withPreSealRename(); const layered: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true')).json(); // The row stored the rename — the customisation is real and readable… - expect(labelOf(layered?.overlay)).toBe('Customer'); + expect(labelOf(layered?.overlay)).toBe(RENAMED_LABEL); // …and neither read that a writable form derives from ever shows it. const after: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); - expect(labelOf(after?.item)).toBe('Customer'); - expect(await listedLabel()).toBe('Customer'); - }); + expect(labelOf(after?.item)).toBe(RENAMED_LABEL); + expect(await listedLabel()).toBe(RENAMED_LABEL); + }, 180_000); it('[#8284] after the rename the three reads still AGREE — on the extension, not the catalog', async () => { // What the ruling bought in the renamed state, pinned so the case above @@ -251,23 +326,18 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc // precedence ADR-0029 D9.2a then settled, so a regression in either is visible // here without this case having to be rewritten when the other moves. // - // Performs its own PUT rather than leaning on the case above: that case - // was written as an `it.fails`, which stops at its first failing - // assertion, so depending on its side effects would have made this - // case's meaning depend on where that happened to be. - const before: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); - const put = await stack.apiAs(token, 'PUT', '/meta/object/showcase_account', { - ...(before?.item ?? {}), label: 'Customer', - }); - expect(put.status).toBeLessThan(400); + // Asks for the renamed state itself rather than leaning on the case + // above, so its meaning does not depend on that case's order or outcome. + // [ADR-0131 D6] The rename is the pre-seal row (see `withPreSealRename`). + await withPreSealRename(); const layered: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true')).json(); - expect(labelOf(layered?.overlay)).toBe('Customer'); + expect(labelOf(layered?.overlay)).toBe(RENAMED_LABEL); const after: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); expect(labelOf(after?.item)).toBe(labelOf(layered?.effective)); expect(await listedLabel()).toBe(labelOf(layered?.effective)); // ⛔ And NOT the catalog string, which is what all three used to serve. expect(labelOf(after?.item)).not.toBe(CATALOG_LABEL); - }); + }, 180_000); }); diff --git a/packages/rest/src/meta-object-owd-gate.test.ts b/packages/rest/src/meta-object-owd-gate.test.ts index 26521bdb451..e13ddd7c66e 100644 --- a/packages/rest/src/meta-object-owd-gate.test.ts +++ b/packages/rest/src/meta-object-owd-gate.test.ts @@ -343,15 +343,14 @@ describe('[#8310] the 422 lint door through PUT /api/v1/meta/object/:name', () = .toContain('security-external-wider-than-internal'); }, 60_000); - it('door ORDER: when lint AND R1 would both refuse, the 422 lint door answers first', async () => { + it('[ADR-0131 D6] door ORDER over a PACKAGED object: the package door answers ahead of lint AND R1, hatch open', async () => { // An env overlay over the packaged object whose body is BOTH // external-wider (lint) and posture-widening against the packaged - // baseline (R1: external `public_read` > declared external `private`). - // The ruling fixes the order: the lint table answers first - // (`saveMetaItem` runs it before `runAuthoringGate`), so the author - // sees the 422 vocabulary, never a coin-flip between two doors. The - // hatch is open because, shut, the package door answers ahead of both - // (`NOT_OVERRIDABLE`) — see `boot`. + // baseline (R1). The lint table used to answer first, because the + // hatch carried the write past the package door. Managed content is + // sealed now: with the hatch open as with it shut, the package door + // answers ahead of both, and nothing is stored. The lint-before-R1 + // order itself stands wherever both are reached. const { put, storedRows } = await boot({ envWritableObject: true }); const res = await put('qa_packaged_account', packagedOverlay({ @@ -359,10 +358,8 @@ describe('[#8310] the 422 lint door through PUT /api/v1/meta/object/:name', () = externalSharingModel: 'public_read', })); - expect(res._status).toBe(422); - expect(res._json?.code).toBe('INVALID_METADATA'); - expect((res._json?.issues ?? []).map((i: any) => i.rule)) - .toContain('security-external-wider-than-internal'); + expect(res._status).toBe(403); + expect(res._json?.code).toBe('NOT_OVERRIDABLE'); expect(await storedRows('qa_packaged_account')).toEqual([]); }, 60_000); }); @@ -406,45 +403,27 @@ describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:n * rather than fixed here — registering a code is the `packages/spec` lane's * call, and this card narrows doors rather than editing the ledger. */ - it('refuses an env overlay that widens a packaged object\'s internal OWD', async () => { - // Declared baseline: `public_read`. The overlay asks for - // `public_read_write`, and leaves the external side unset so R2 has - // nothing to compare — only R1 can produce this refusal. - const { put, storedRows } = await boot({ envWritableObject: true }); - - const res = await put('qa_packaged_account', packagedOverlay({ - sharingModel: 'public_read_write', - })); - - expect(res._status).toBe(403); - // [#9232] The closed member the 403 derives, with the gate's own - // spelling demoted beside it rather than dropped. - expect(res._json?.code).toBe('PERMISSION_DENIED'); - expect(res._json?.declaredCode).toBe('owd_widening_forbidden'); - expect(String(res._json?.error)).toContain('TIGHTEN'); - expect(await storedRows('qa_packaged_account')).toEqual([]); - }, 60_000); - - it('refuses a widened EXTERNAL side against the packaged baseline', async () => { - // Distinct from the lint door: `public_read` external against - // `public_read` internal is NOT external-wider, so the 422 lint table - // passes the body. Only the comparison against the PACKAGED - // declaration (external `private`) can refuse it — which is exactly - // why R1 SURVIVES the #8310 retirement while R2 did not. A suite that - // only ever sent an external-wider pair could not tell the doors - // apart. - const { put, storedRows } = await boot({ envWritableObject: true }); - - const res = await put('qa_packaged_account', packagedOverlay({ - sharingModel: 'public_read', - externalSharingModel: 'public_read', - })); - - expect(res._status).toBe(403); - expect(res._json?.code).toBe('PERMISSION_DENIED'); - expect(res._json?.declaredCode).toBe('owd_widening_forbidden'); - expect(await storedRows('qa_packaged_account')).toEqual([]); - }, 60_000); + // [ADR-0131 D6] These two cases drove R1 through the hatch and pinned its + // `owd_widening_forbidden` refusal. Managed content is sealed now: the + // hatch opens no overlay of a packaged object, so the package door refuses + // both writes ahead of R1 (`NOT_OVERRIDABLE`), and R1's packaged-baseline + // comparison has no write that reaches it through this door. Pinned as + // that, rather than deleted, so the change in who answers is visible. + for (const [label, over] of [ + ['widens a packaged object\'s internal OWD', { sharingModel: 'public_read_write' }], + ['widens the EXTERNAL side against the packaged baseline', { sharingModel: 'public_read', externalSharingModel: 'public_read' }], + ] as const) { + it(`[ADR-0131 D6] an env overlay that ${label} is refused by the package door before R1, hatch open`, async () => { + const { put, storedRows } = await boot({ envWritableObject: true }); + + const res = await put('qa_packaged_account', packagedOverlay(over)); + + expect(res._status).toBe(403); + expect(res._json?.code).toBe('NOT_OVERRIDABLE'); + expect(res._json?.declaredCode).toBeUndefined(); + expect(await storedRows('qa_packaged_account')).toEqual([]); + }, 60_000); + } }); // --------------------------------------------------------------------------- @@ -477,24 +456,23 @@ describe('[#7674] what the gate must still let through', () => { expect(await storedRows('qa_probe')).toHaveLength(1); }, 60_000); - it('an env overlay that TIGHTENS a packaged object is allowed (R1 is directional)', async () => { - // `OS_METADATA_WRITABLE=object` is the escape hatch R1's own docblock - // names as the path it judges. Without it the overlay is refused by - // `saveMetaItem`'s package door (`NOT_OVERRIDABLE`) — a DIFFERENT - // door, ahead of the posture gate — and this case would then pass for - // a reason that has nothing to do with the posture gate. + it('[ADR-0131 D6] an env overlay that TIGHTENS a packaged object is sealed too — refused by the package door, hatch open', async () => { + // This was R1's directional control: through the hatch, the overlay + // narrowing the packaged baseline (`public_read` / `private` → + // `private` / `private`) landed. Managed content is sealed now, so no + // overlay of a packaged object lands through this door in either + // direction; the posture gate's directionality stays pinned in its + // own suite. const { put, storedRows } = await boot({ envWritableObject: true }); - // The packaged baseline is `public_read` / `private`; the overlay - // narrows the internal side to `private`. ADR-0086 D1 permits exactly - // this direction, and R1 refusing it would be the overshoot. const res = await put('qa_packaged_account', packagedOverlay({ sharingModel: 'private', externalSharingModel: 'private', })); - expect(res._status, `unexpected refusal: ${JSON.stringify(res._json)}`).toBe(200); - expect(await storedRows('qa_packaged_account')).toHaveLength(1); + expect(res._status).toBe(403); + expect(res._json?.code).toBe('NOT_OVERRIDABLE'); + expect(await storedRows('qa_packaged_account')).toEqual([]); }, 60_000); /** diff --git a/packages/rest/src/rest-meta-managed-seal-hatch.test.ts b/packages/rest/src/rest-meta-managed-seal-hatch.test.ts new file mode 100644 index 00000000000..33c758d2aad --- /dev/null +++ b/packages/rest/src/rest-meta-managed-seal-hatch.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] Managed content is sealed — the `OS_METADATA_WRITABLE` hatch no + * longer opens a write onto, or a removal of, an item a managed package ships, + * at the REAL `/api/v1/meta/:type/:name` routes. + * + * The protocol-side pins are `protocol.packaged-base-refusal.test.ts` and + * `sys-metadata-repository.package-writability.test.ts` in + * `@objectstack/metadata-protocol`. This file answers what they cannot: the + * REST door relays the seal — `403` and `NOT_OVERRIDABLE` — for every type the + * hatch used to open, under both spellings of the variable, on both kernel + * shapes, and the sentence that reaches the client names the managed package. + * + * Same harness as `rest-meta-packaged-flow-refusal.test.ts`: a REAL + * `ObjectStackProtocolImplementation` over a REAL `SchemaRegistry`, the + * packaged items registered under a package id the way an artifact loader + * registers them. Every refusal lands before any store is touched, so no driver + * is booted. The removals run on an environment kernel, where the removal door + * is asked before the store; the end-to-end removal readings, the controls + * (a regime-O overlay, a new flow, the switch, the clone) and the legacy-row + * cases are pinned on a booted app in + * `packages/qa/dogfood/test/managed-content-sealed.dogfood.test.ts`. + */ + +import { afterEach, describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { RestServer } from './rest-server.js'; + +const PACKAGE_ID = 'com.example.pkg'; +const HATCH = 'flow,object,field,permission,position'; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, setHeader: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const OBJECT = { + name: 'pkg_invoice', + label: 'Invoice', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, +}; + +function boot(environmentId: string | undefined) { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + registry.registerItem('flow', { name: 'pkg_flow', label: 'Alert', type: 'autolaunched', nodes: [], edges: [] }, 'name', PACKAGE_ID); + registry.registerObject(OBJECT as never, PACKAGE_ID); + registry.registerItem('permission', { name: 'pkg_perm', label: 'Perm', objects: {} }, 'name', PACKAGE_ID); + registry.registerItem('position', { name: 'pkg_position', label: 'Position' }, 'name', PACKAGE_ID); + const protocol = new ObjectStackProtocolImplementation( + { registry, findOne: async () => null } as never, () => new Map(), environmentId, + ); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + // The write doors demand `manage_metadata`; held here, so every 403 below is the SEAL. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_admin', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + const route = (method: string) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === '/api/v1/meta/:type/:name'); + if (!found) throw new Error(`${method} /api/v1/meta/:type/:name is not registered`); + return found; + }; + const call = async (method: 'PUT' | 'DELETE', type: string, name: string, body?: unknown) => { + const res = makeRes(); + await route(method).handler({ method, params: { type, name }, query: {}, headers: {}, body } as any, res); + return { status: res._status, code: res._json?.code, message: String(res._json?.error ?? '') }; + }; + return { call }; +} + +function open(variable: 'OS_METADATA_WRITABLE' | 'OBJECTSTACK_METADATA_WRITABLE') { + process.env[variable] = HATCH; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +} + +afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + delete process.env.OBJECTSTACK_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +/** The managed items the hatch used to open, and the PUT body each one is edited with. */ +const EDITS: ReadonlyArray]> = [ + ['flow', 'pkg_flow', { name: 'pkg_flow', label: 'Edited', type: 'autolaunched', nodes: [], edges: [] }], + ['object', 'pkg_invoice', { ...OBJECT, fields: { name: { type: 'text', label: 'Renamed field' } } }], + ['field', 'pkg_invoice.name', { name: 'name', type: 'text', label: 'Renamed field' }], + ['permission', 'pkg_perm', { name: 'pkg_perm', label: 'Edited', objects: {} }], + ['position', 'pkg_position', { name: 'pkg_position', label: 'Edited' }], +]; + +describe('[ADR-0131 D6] the hatch opens no PUT onto a managed item at the REST door', () => { + for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; + it(`${variable}=${HATCH}: PUT of each managed item answers 403 NOT_OVERRIDABLE, as with the hatch shut (${kernel} kernel)`, async () => { + const shut = boot(environmentId); + const sealed = await Promise.all(EDITS.map(([type, name, body]) => shut.call('PUT', type, name, body))); + open(variable); + const { call } = boot(environmentId); + for (const [i, [type, name, body]] of EDITS.entries()) { + const r = await call('PUT', type, name, body); + expect({ status: r.status, code: r.code }, `${type}/${name}`).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(r.message, `${type}/${name}`).toBe(sealed[i].message); + expect(r.message, `${type}/${name}`).toContain('managed package'); + } + }); + } + } +}); + +describe('[ADR-0131 D6] the hatch opens no removal of a managed item at the REST door', () => { + for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { + it(`${variable}=${HATCH}: DELETE of a managed flow and a managed object answers 403 NOT_OVERRIDABLE (environment kernel)`, async () => { + open(variable); + const { call } = boot('env_1'); + for (const [type, name] of [['flow', 'pkg_flow'], ['object', 'pkg_invoice']] as const) { + const r = await call('DELETE', type, name); + expect({ status: r.status, code: r.code }, `${type}/${name}`).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(r.message, `${type}/${name}`).toContain('is provided by a managed package and is sealed against removal'); + } + }); + } +}); diff --git a/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts b/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts index 6c5863f35b9..011de0d05e8 100644 --- a/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts +++ b/packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts @@ -49,7 +49,7 @@ import { RestServer } from './rest-server.js'; const PACKAGE_ID = 'com.example.pkg'; const PACKAGED_ACTION = 'pkg_approve'; const PACKAGED_PERMISSION = 'pkg_perm'; -/** 88 characters — the longest name the flow row (411 before the name) still delivers whole. */ +/** 88 characters — a name the flow row (395 characters outside the name) still delivers whole. */ const LONG_ACTION = `pkg_${'x'.repeat(84)}`; function createMockServer() { @@ -99,7 +99,7 @@ function boot(environmentId: string | undefined = 'env_1') { const expectRegimeC = (message: unknown, type: string, name: string, operation: 'save' | 'delete') => { const text = String(message); expect(text.startsWith( - `Metadata item '${type}/${name}' is provided by a code package, and its packaged base is locked ` + `Metadata item '${type}/${name}' is provided by a managed package and is sealed ` + (operation === 'delete' ? 'against removal. ' : 'against in-place edits. '), )).toBe(true); expect(text).not.toContain('OS_METADATA_WRITABLE'); diff --git a/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts b/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts index a2fa35d7a4b..1891737c516 100644 --- a/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts +++ b/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts @@ -107,16 +107,16 @@ describe('PUT / DELETE /api/v1/meta/flow/:name on a packaged flow — the refusa expectRegimeC(r.message); }); - it('control: a packaged `page` (no declared regime) reads the sentence it always read', async () => { + it('control: a packaged `page` (no declared regime) reads the managed seal (ADR-0131 D6)', async () => { const { call } = boot('env_1'); const r = await call('PUT', 'page', PACKAGED_PAGE, { name: PACKAGED_PAGE, label: 'Changed in place' }); expect(r.status).toBe(403); expect(r.code).toBe('NOT_OVERRIDABLE'); expect(r.message).toBe( - `Metadata item 'page/${PACKAGED_PAGE}' is provided by a code package ` - + 'and the type has not opted into per-org overlay writes (allowOrgOverride=false). ' - + 'Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. ' - + 'See docs/adr/0005-metadata-customization-overlay.md.', + `Metadata item 'page/${PACKAGED_PAGE}' is provided by a managed package and is sealed against in-place edits: ` + + 'its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not open ' + + 'a managed item. Edit the source artifact and redeploy. ' + + 'See docs/adr/0131-total-organization-ownership-no-null-organization-id.md.', ); }); }); diff --git a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts index 181de2d7725..f2506139386 100644 --- a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts +++ b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts @@ -368,22 +368,35 @@ describe('what the lock leaves open', () => { expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false); }); - it('the operator hatch (OS_METADATA_WRITABLE) opens this door exactly as it opens /meta', async () => { - // The verdict reads the same `isOverlayAllowed` the metadata door - // reads, hatch included — never a copy that forgets it. [#20819] The - // Regime C refusal no longer NAMES the hatch (it names clone and the - // switch); which writes the lock refuses did not move, so the hatch - // still opens both doors alike. + it('[ADR-0131 D6] the operator hatch (OS_METADATA_WRITABLE) opens this door exactly as it opens /meta: not at all', async () => { + // The verdict reads the same predicate the metadata door reads — never + // a copy. Managed content is sealed: the hatch opens no write onto, + // and no removal of, a managed flow, so both definition doors answer + // with the hatch open what they answer with it shut, and nothing is + // registered or unregistered. The sanctioned primitives stay open with + // it set too: the switch (§7.2) and the clone under a new name (§7.1). process.env.OS_METADATA_WRITABLE = 'flow'; ObjectStackProtocolImplementation.resetEnvWritableCache(); const h = boot(); h.standInStore(); - const { response } = await h.dispatcher.handleAutomation( + const put = await h.dispatcher.handleAutomation( `/${PACKAGED}`, 'PUT', definitionOf(PACKAGED, 'Operator edit'), AUTHOR(), undefined, ); - expect(statusOf(response)).toBe(200); - expect(h.registerFlow).toHaveBeenCalledTimes(1); + expect(statusOf(put.response)).toBe(403); + expect(errorOf(put.response).code).toBe('NOT_OVERRIDABLE'); + const del = await h.dispatcher.handleAutomation(`/${PACKAGED}`, 'DELETE', undefined, AUTHOR(), undefined); + expect(statusOf(del.response)).toBe(403); + expect(errorOf(del.response).code).toBe('NOT_OVERRIDABLE'); + expect(h.registerFlow).not.toHaveBeenCalled(); + expect(h.unregisterFlow).not.toHaveBeenCalled(); + expect(h.held(PACKAGED)).toBeDefined(); + + const toggle = await h.dispatcher.handleAutomation( + `/${PACKAGED}/toggle`, 'POST', { enabled: false }, AUTHOR(), undefined, + ); + expect(statusOf(toggle.response)).toBe(200); + expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false); }); }); diff --git a/packages/runtime/src/meta-managed-content-seal.test.ts b/packages/runtime/src/meta-managed-content-seal.test.ts new file mode 100644 index 00000000000..5f78dd5d3fe --- /dev/null +++ b/packages/runtime/src/meta-managed-content-seal.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6] Managed content is sealed — pinned at the RUNTIME DISPATCHER's + * `/meta` door (`HttpDispatcher.handleMetadata`, the transport the + * `@objectstack/hono` catch-all serves), beside the REST door's pin + * (`packages/rest/src/rest-meta-managed-seal-hatch.test.ts`). + * + * The `OS_METADATA_WRITABLE` hatch used to open a write onto an item a managed + * package ships for every type it named: a flow, an object (and through it a + * field), a permission set, a position. With the hatch open the dispatcher now + * answers what it answers with the hatch shut — `403` `NOT_OVERRIDABLE`, no + * row — under both spellings the protocol's reader honours, on both kernel + * shapes; and the read envelope stops promising an edit the door refuses. + * + * What stays open, pinned beside it: + * - a regime-O overlay: a packaged VIEW is overlaid (`200`, a row), because + * the registry allows that type an environment overlay; + * - the hatch's TYPE-level unlock for an item no managed package ships: a new + * flow is created (`200`, a row) with the hatch open as with it shut. + * + * Every case drives the REAL `HttpDispatcher`, the REAL + * `ObjectStackProtocolImplementation` and the REAL `SysMetadataRepository` + * over a `sys_metadata`-shaped store, and reads the stored rows back: a 403 with + * a row behind it is the defect wearing a different status code. The store and + * registry doubles follow `meta-field-overlay-lock.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { HttpDispatcher } from './http-dispatcher.js'; +import type { HttpDispatcherResult } from './http-dispatcher.js'; + +const PKG = 'com.example.crm'; +const HATCH = 'flow,object,field,permission,position'; + +const PACKAGED_FLOW = { name: 'crm_alert', label: 'Alert', type: 'autolaunched', nodes: [], edges: [], _packageId: PKG }; +const PACKAGED_OBJECT = { + name: 'crm_lead', + label: 'Lead', + // [#8310] The runtime object door requires an authored OWD — without it the + // 422 lint door could answer first and a refusal would pass for the wrong reason. + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Lead Name', required: true } }, + _packageId: PKG, +}; +const PACKAGED_PERMISSION = { name: 'crm_sales_user', label: 'Sales User', objects: {}, _packageId: PKG }; +const PACKAGED_POSITION = { name: 'sales_rep', label: 'Sales Representative', _packageId: PKG }; +const PACKAGED_VIEW = { + name: 'crm_lead.all', + label: 'All Leads', + object: 'crm_lead', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], + _packageId: PKG, +}; + +const strip = (item: Record) => + Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); + +/** The managed items the hatch used to open, and the PUT each one is edited with. */ +const EDITS: ReadonlyArray]> = [ + ['flow', PACKAGED_FLOW.name, { ...strip(PACKAGED_FLOW), label: 'Edited in place' }], + ['object', PACKAGED_OBJECT.name, { + ...strip(PACKAGED_OBJECT), fields: { name: { type: 'text', label: 'Renamed field', required: true } }, + }], + ['field', `${PACKAGED_OBJECT.name}.name`, { name: 'name', type: 'text', label: 'Renamed field' }], + ['permission', PACKAGED_PERMISSION.name, { ...strip(PACKAGED_PERMISSION), label: 'Edited in place' }], + ['position', PACKAGED_POSITION.name, { ...strip(PACKAGED_POSITION), label: 'Edited in place' }], +]; + +interface Row { id: string; [k: string]: unknown } + +function matches(row: Row, where: Record | undefined): boolean { + if (!where) return true; + for (const [key, cond] of Object.entries(where)) { + if (cond === undefined) continue; + if (key === '$or') { + if (!(cond as Array>).some((b) => matches(row, b))) return false; + continue; + } + const value = row[key]; + if (cond !== null && typeof cond === 'object') { + const op = cond as Record; + if ('$null' in op) { + if ((value === null || value === undefined) !== (op.$null === true)) return false; + continue; + } + if ('$in' in op) { + if (!(op.$in as unknown[]).includes(value)) return false; + continue; + } + continue; + } + if (cond === null) { + if (value !== null && value !== undefined) return false; + continue; + } + if (value !== cond) return false; + } + return true; +} + +function makeEngine() { + const tables = new Map(); + let nextId = 0; + const tableOf = (name: string) => { + let t = tables.get(name); + if (!t) { t = []; tables.set(name, t); } + return t; + }; + const artifacts = new Map>([ + ['flow', new Map([[PACKAGED_FLOW.name, PACKAGED_FLOW]])], + ['object', new Map([[PACKAGED_OBJECT.name, PACKAGED_OBJECT]])], + ['permission', new Map([[PACKAGED_PERMISSION.name, PACKAGED_PERMISSION]])], + ['position', new Map([[PACKAGED_POSITION.name, PACKAGED_POSITION]])], + ['view', new Map([[PACKAGED_VIEW.name, PACKAGED_VIEW]])], + ]); + const runtimeItems = new Map>(); + const engine: any = { + registry: { + listItems: (type: string) => [ + ...Array.from(artifacts.get(type)?.values() ?? []), + ...Array.from(runtimeItems.get(type)?.values() ?? []), + ], + getItem: (type: string, name: string) => runtimeItems.get(type)?.get(name) ?? artifacts.get(type)?.get(name), + getArtifactItem: (type: string, name: string) => artifacts.get(type)?.get(name), + getObject: (name: string) => artifacts.get('object')?.get(name) ?? runtimeItems.get('object')?.get(name), + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + // The producer's arity: `(type, item, keyStrategy, ownerId?)`. + registerItem: (type: string, item: any, keyStrategy?: string) => { + const key = keyStrategy === 'object' ? (item?.object as string) : (item?.name as string); + if (!key) return; + let byName = runtimeItems.get(type); + if (!byName) { byName = new Map(); runtimeItems.set(type, byName); } + byName.set(key, item); + }, + registerObject: () => {}, + }, + async find(table: string, opts?: { where?: Record; limit?: number }) { + const rows = tableOf(table).filter((r) => matches(r, opts?.where)); + // The caller's bound, applied after the filter (`check:objectql-double-limit`). + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(table: string, opts?: { where?: Record }) { + assertEngineFindOnePredicate(table, opts); + return tableOf(table).find((r) => matches(r, opts?.where)) ?? null; + }, + async insert(table: string, data: Record) { + nextId += 1; + const row: Row = { id: (data.id as string) ?? `r_${nextId}`, ...data }; + tableOf(table).push(row); + return row; + }, + // [#5619] Both write verbs open with the PRODUCER's own dispatch predicate + // (`check:engine-double-contract`). + async update(table: string, data: Record, opts?: { where?: Record }) { + const dispatch = assertEngineUpdateDispatch(data as any, opts as any); + const rows = tableOf(table); + const target = dispatch.kind === 'by-id' + ? rows.find((r) => r.id === dispatch.id) + : rows.find((r) => matches(r, opts?.where)); + if (target) Object.assign(target, data); + return target ?? null; + }, + async delete(table: string, opts?: { where?: Record }) { + const dispatch = assertEngineDeleteDispatch(opts as any); + const rows = tableOf(table); + const keep = dispatch.kind === 'by-id' + ? rows.filter((r) => r.id !== dispatch.id) + : rows.filter((r) => !matches(r, opts?.where)); + tables.set(table, keep); + return { deleted: rows.length - keep.length }; + }, + async count(table: string, opts?: { where?: Record }) { + return tableOf(table).filter((r) => matches(r, opts?.where)).length; + }, + async aggregate() { return []; }, + async execute() { return undefined; }, + metaRows: () => tableOf('sys_metadata'), + }; + return engine; +} + +function makeStack(environmentId?: string) { + const engine = makeEngine(); + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId); + const services: Record = { + protocol, + objectql: { registry: engine.registry }, + auth: { api: { getSession: async () => ({ session: {} }) } }, + }; + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return { engine, dispatcher: new HttpDispatcher(kernel) }; +} + +/** An authorized caller: `manage_metadata` held, so a 403 below is the seal, never the capability gate. */ +const ctx = (): any => ({ + request: { headers: {} }, + environmentId: 'env_1', + executionContext: { userId: 'usr_1', systemPermissions: ['manage_metadata'] }, +}); + +function responseOf(result: HttpDispatcherResult): NonNullable { + if (!result.response) throw new Error('the dispatcher handled the route but returned no response'); + return result.response; +} + +const activeRow = (engine: any, type: string, name: string) => + engine.metaRows().find((r: any) => r.type === type && r.name === name && r.state === 'active'); + +function resetHatch() { + delete process.env.OS_METADATA_WRITABLE; + delete process.env.OBJECTSTACK_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +} + +beforeEach(() => { + resetHatch(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + resetHatch(); + vi.restoreAllMocks(); +}); + +describe('[ADR-0131 D6] the dispatcher /meta door: the hatch opens no write onto a managed item', () => { + for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; + + it(`${variable}=${HATCH}: PUT of a managed flow, object, field, permission set and position — 403 NOT_OVERRIDABLE, no row (${kernel})`, async () => { + const shut = makeStack(environmentId); + const sealed: Array> = []; + for (const [type, name, body] of EDITS) { + sealed.push(responseOf(await shut.dispatcher.handleMetadata(`/${type}/${name}`, ctx(), 'PUT', body))); + } + + process.env[variable] = HATCH; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const { engine, dispatcher } = makeStack(environmentId); + for (const [i, [type, name, body]] of EDITS.entries()) { + const res = responseOf(await dispatcher.handleMetadata(`/${type}/${name}`, ctx(), 'PUT', body)); + expect(res.status, `${type}/${name}`).toBe(403); + expect(res.body?.error?.code, `${type}/${name}`).toBe('NOT_OVERRIDABLE'); + // The same refusal the shut hatch gets — sentence included. + expect(res.body?.error?.message, `${type}/${name}`).toBe(sealed[i].body?.error?.message); + expect(activeRow(engine, type, name), `${type}/${name}`).toBeUndefined(); + } + expect(engine.metaRows()).toEqual([]); + }); + + it(`${variable}=${HATCH}: the read envelope promises no edit and no removal of a managed flow (${kernel})`, async () => { + process.env[variable] = HATCH; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const { dispatcher } = makeStack(environmentId); + const res = responseOf(await dispatcher.handleMetadata(`/flow/${PACKAGED_FLOW.name}`, ctx(), 'GET', undefined)); + expect(res.status).toBe(200); + const envelope = (res.body?.data ?? res.body) as { editable?: unknown; deletable?: unknown }; + expect({ editable: envelope?.editable, deletable: envelope?.deletable }).toEqual({ editable: false, deletable: false }); + }); + + it(`${variable}=${HATCH} — controls: a packaged VIEW is overlaid, and a NEW flow is created (${kernel})`, async () => { + process.env[variable] = HATCH; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const { engine, dispatcher } = makeStack(environmentId); + + const view = responseOf(await dispatcher.handleMetadata( + `/view/${PACKAGED_VIEW.name}`, ctx(), 'PUT', { ...strip(PACKAGED_VIEW), label: 'My Leads' }, + )); + expect(view.status, JSON.stringify(view.body)).toBe(200); + expect(activeRow(engine, 'view', PACKAGED_VIEW.name)).toBeDefined(); + + const created = responseOf(await dispatcher.handleMetadata( + '/flow/crm_new_alert', ctx(), 'PUT', + { name: 'crm_new_alert', label: 'New Alert', type: 'autolaunched', nodes: [], edges: [] }, + )); + expect(created.status, JSON.stringify(created.body)).toBe(200); + expect(activeRow(engine, 'flow', 'crm_new_alert')).toBeDefined(); + }); + } + } +}); diff --git a/packages/runtime/src/meta-overlay-read-your-writes.test.ts b/packages/runtime/src/meta-overlay-read-your-writes.test.ts index 1c44013843e..0c26eb9cd1b 100644 --- a/packages/runtime/src/meta-overlay-read-your-writes.test.ts +++ b/packages/runtime/src/meta-overlay-read-your-writes.test.ts @@ -209,6 +209,11 @@ describe('#4521 — read-your-writes between saveMeta and the dispatch path', () // shadow/restore machinery pinned here is exactly what an operator // behind the hatch exercises. (Every other case in this file writes // brand-new names, which ride `allowRuntimeCreate` untouched.) + // + // [ADR-0131 D6] Managed content is sealed: the hatch no longer opens a + // write onto an action a managed package ships, so this case now pins + // the seal at the dispatch path — the save is refused and the SHIPPED + // declaration keeps resolving, never shadowed. process.env.OS_METADATA_WRITABLE = 'action'; (ObjectStackProtocolImplementation as any).resetEnvWritableCache(); resetEnvWritableMetadataTypes(); @@ -216,11 +221,11 @@ describe('#4521 — read-your-writes between saveMeta and the dispatch path', () registry.registerItem('action', { name: 'shipped_probe', label: 'Shipped', type: 'script', target: 'showcase.shipped' }, 'name', 'showcase'); expect((await resolve('shipped_probe')).action?.label).toBe('Shipped'); - await saveAction({ name: 'shipped_probe', label: 'Customized', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }); - expect((await resolve('shipped_probe')).action?.label).toBe('Customized'); - - await protocol.deleteMetaItem({ type: 'action', name: 'shipped_probe' }); + const refused: any = await saveAction({ name: 'shipped_probe', label: 'Customized', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }) + .then(() => null, (e: unknown) => e); + expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); expect((await resolve('shipped_probe')).action?.label).toBe('Shipped'); + expect(engine.getRows().filter((r: any) => r.name === 'shipped_probe' && 'metadata' in r)).toEqual([]); }); afterEach(() => { @@ -369,6 +374,13 @@ describe('#5079 — list / get / dispatch agree immediately after deleteMeta', ( // as the sibling `#4521` case above does. (Every other case in this // block writes brand-new names, which ride `allowRuntimeCreate` // untouched, so only this one needs the hatch.) + // + // [ADR-0131 D6] Managed content is sealed: the hatch no longer opens the + // customization this case used to write first, so no overlay row + // exists to reset. The boundary still holds and is pinned on what is + // left: a DELETE of the shipped name never retires it — this + // host-config kernel answers the no-op receipt, and the shipped value + // stays listed and dispatchable. process.env.OS_METADATA_WRITABLE = 'action'; (ObjectStackProtocolImplementation as any).resetEnvWritableCache(); resetEnvWritableMetadataTypes(); @@ -379,11 +391,12 @@ describe('#5079 — list / get / dispatch agree immediately after deleteMeta', ( 'name', 'showcase', ); - await saveAction({ name: 'shipped_probe', label: 'Customized', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }); - expect((await surfaces('shipped_probe')).item?.label).toBe('Customized'); + const refused: any = await saveAction({ name: 'shipped_probe', label: 'Customized', objectName: 'showcase_task', type: 'script', target: 'showcase.probe' }) + .then(() => null, (e: unknown) => e); + expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); const deleted = await protocol.deleteMetaItem({ type: 'action', name: 'shipped_probe' }); - expect(deleted.message).toContain('reset to artifact default'); + expect(deleted.message).toContain('already at artifact default'); const after = await surfaces('shipped_probe'); expect(after.listedNames).toContain('shipped_probe'); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3212eb32480..7a14a635986 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3961,6 +3961,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/meta-managed-content-seal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/runtime/src/meta-managed-content-seal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/runtime/src/meta-managed-content-seal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/meta-overlay-read-your-writes.test.ts", "verb": "delete",