Skip to content
19 changes: 19 additions & 0 deletions .changeset/22411-hatch-legacy-spelling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: one reader of `OS_METADATA_WRITABLE` — the legacy `OBJECTSTACK_METADATA_WRITABLE`, removed in 11.0, no longer opens the hatch at the type listing either

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) nothing authorable is removed, renamed or narrowed: no spec key, no metadata spelling, no export and no stored row changes shape, so there is nothing for `os migrate meta` to rewrite and no ledger entry to make. The operator action below is a deployment-environment rename, which the ADR-0087 ledger does not carry; 11.0 already published it, and this change makes the last reader honour it. -->

**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`.

This finishes 11.0's removal at the reader that kept it. The 11.0 release removed ObjectStack's own legacy environment-variable names, `OBJECTSTACK_METADATA_WRITABLE` among them. That change edited one of the two readers of `OS_METADATA_WRITABLE` (the metadata repository's write gate) and missed the other (the protocol's, which feeds `GET /api/v1/meta/types` and the protocol's write gates). So with only the legacy spelling set, the type listing reported a named type as writable (`allowOrgOverride: true`, `overrideSource: 'env'`), while creating a new item of that type answered `403 NOT_CREATABLE`. One deployment gave two answers to one question.

**What changes.** The protocol now reads the setting through the repository's reader, so there is one reader and it reads `OS_METADATA_WRITABLE` only. With only `OBJECTSTACK_METADATA_WRITABLE` set, the hatch is shut everywhere: the listing reports no env override, and every write the hatch would open is refused as it is with nothing set. No deprecation warning is printed for the legacy name any more, because nothing reads it.

**What does not change.** `OS_METADATA_WRITABLE` behaves exactly as before.

**Operator action.** A deployment that still sets `OBJECTSTACK_METADATA_WRITABLE` sets `OS_METADATA_WRITABLE` in its place, with the same comma-separated type list. This is the rename 11.0 already published; nothing else changes.
3 changes: 1 addition & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ the hosted ObjectOS Cloud control plane.

Some env vars accept a legacy alias for compatibility. **Ecosystem-standard names** (e.g. `DATABASE_URL`, `AUTH_SECRET`, `BETTER_AUTH_*`, `PORT`, `CORS_*`, `MCP_SERVER_*`) are permanently accepted and no longer warn. ObjectStack's own former names are deprecated — prefer the canonical `OS_*`.

> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`.
> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `OBJECTSTACK_METADATA_WRITABLE` → `OS_METADATA_WRITABLE`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`.

| Canonical | Legacy |
|:---|:---|
Expand All @@ -391,6 +391,5 @@ Some env vars accept a legacy alias for compatibility. **Ecosystem-standard name
| `OS_MCP_SERVER_ENABLED` | `MCP_SERVER_ENABLED` |
| `OS_MCP_SERVER_NAME` | `MCP_SERVER_NAME` |
| `OS_MCP_SERVER_TRANSPORT` | `MCP_SERVER_TRANSPORT` |
| `OS_METADATA_WRITABLE` | `OBJECTSTACK_METADATA_WRITABLE` |
| `OS_HOME` | `OBJECTSTACK_HOME` |
| `OS_DEV_CRYPTO_KEY` | `OBJECTSTACK_DEV_CRYPTO_KEY` |
118 changes: 82 additions & 36 deletions packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
* deliberately `null` — a name no package ships, a Regime O overlay type,
* 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.
* a managed package ships is the same with `OS_METADATA_WRITABLE` set or
* not. [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE`, which
* 11.0 removed, is not read at all: the hatch is shut under it.
*
* The registry double serves only `getArtifactItem`, which is all the verdict
* reads; what it returns is what the real `SchemaRegistry` returns for an
* The registry double serves `getArtifactItem`, which is all the verdict
* reads, and `getRegisteredTypes`, which the type listing reads to show the
* hatch shut under the removed spelling; what `getArtifactItem` returns is what the real `SchemaRegistry` returns for an
* artifact a code package registered (`_packageId` stamped, package
* provenance). `@objectstack/objectql` cannot be imported here: it depends on
* this package.
Expand All @@ -46,7 +48,10 @@ const ARTIFACTS = new Map<string, Map<string, unknown>>([
]);

function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) };
const registry = {
getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name),
getRegisteredTypes: () => Array.from(ARTIFACTS.keys()),
};
return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId);
}

Expand Down Expand Up @@ -120,37 +125,78 @@ describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed
.toThrow('registry unreadable');
});

// [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();
});
}
// [ADR-0131 D6] Managed content is sealed: the hatch 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 environmentId of [undefined, 'env_1']) {
it(`OS_METADATA_WRITABLE=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.OS_METADATA_WRITABLE = '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();
});
}

// [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE` was removed in
// 11.0, and the protocol now reads the setting through the repository's one
// reader, which reads the preferred spelling only. Under the legacy
// spelling alone the hatch is SHUT: the type listing reports no env
// override, and every verdict is the one the shut hatch gives. Control: the
// preferred spelling still opens the listing.
for (const environmentId of [undefined, 'env_1']) {
it(`OBJECTSTACK_METADATA_WRITABLE=flow,page is not read — the hatch is shut (${environmentId ? 'environment' : 'host-config'} kernel)`, async () => {
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,
};
const hatchOf = async (p: ObjectStackProtocolImplementation) => {
const { entries } = await (p as any).getMetaTypes();
return Object.fromEntries(['flow', 'page'].map((t) => {
const e = entries.find((x: any) => x.type === t);
return [t, { allowOrgOverride: e?.allowOrgOverride, overrideSource: e?.overrideSource }];
}));
};
const SHUT = { allowOrgOverride: false, overrideSource: 'registry' };
expect(await hatchOf(shut)).toEqual({ flow: SHUT, page: SHUT });

process.env.OBJECTSTACK_METADATA_WRITABLE = 'flow,page';
ObjectStackProtocolImplementation.resetEnvWritableCache();
const legacy = protocolOn(environmentId);
expect(await hatchOf(legacy)).toEqual({ flow: SHUT, page: SHUT });
for (const verb of ['save', 'delete', 'page'] as const) {
const verdict: any = verb === 'page'
? legacy.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' })
: legacy.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: verb });
expect(shape(verdict), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
expect(verdict.message, verb).toBe(sealed[verb].message);
}

// Control: the preferred spelling is read, so the same names open the listing.
process.env.OS_METADATA_WRITABLE = 'flow,page';
ObjectStackProtocolImplementation.resetEnvWritableCache();
const OPEN = { allowOrgOverride: true, overrideSource: 'env' };
expect(await hatchOf(protocolOn(environmentId))).toEqual({ flow: OPEN, page: OPEN });
});
}
});

Expand Down
44 changes: 23 additions & 21 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
DataProtocol, MetadataProtocol, PackageProtocol,
} from '@objectstack/spec/api';
import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core';
import { declaredUserMessage, readEnvWithDeprecation, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
// [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?".
// `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering
// this question with it is a bug (cloud#1020, #5233) — so the posture, and only
Expand Down Expand Up @@ -43,7 +43,14 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
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 {
DraftConflictError,
SysMetadataRepository,
envWritableMetadataTypes,
packageScopedRowWhere,
resetEnvWritableMetadataTypes,
type SysMetadataEngine,
} from './sys-metadata-repository.js';
import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js';
import {
resolveArtifactLockLayer,
Expand Down Expand Up @@ -15860,29 +15867,24 @@ export class ObjectStackProtocolImplementation implements
* use to enable Studio-side editing of types whose protocol-level flag
* is still false (object, field, permission, …).
*
* Memoised at first call. Tests can override by clearing the cache via
* [#22411] Delegates to the repository's {@link envWritableMetadataTypes},
* the ONE reader of the setting, so the listing and the save door read the
* same set. This used to be a second copy of the parse. The 11.0 removal of
* the legacy `OBJECTSTACK_METADATA_WRITABLE` spelling edited the other
* copy and missed this one, so the listing advertised a hatch that the
* repository then refused. ⛔ Never parse the variable here again.
*
* Memoised at first call (by the shared reader). Tests can override by
* clearing the cache via
* {@link ObjectStackProtocolImplementation.resetEnvWritableCache}.
*/
private static _envWritableTypes: Set<string> | null = null;
private static envWritableTypes(): ReadonlySet<string> {
if (this._envWritableTypes !== null) return this._envWritableTypes;
const raw = readEnvWithDeprecation('OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE') || '';
const set = new Set<string>();
for (const tok of raw.split(',')) {
const t = tok.trim();
if (!t) continue;
const singular = PLURAL_TO_SINGULAR[t] ?? t;
set.add(singular);
const plural = SINGULAR_TO_PLURAL[singular];
if (plural) set.add(plural);
}
this._envWritableTypes = set;
return set;
}

/** Test hook — clear the memoised env-writable cache. */
return envWritableMetadataTypes();
}

/** Test hook — clear the memoised env-writable cache (the shared reader's one cache). */
static resetEnvWritableCache(): void {
this._envWritableTypes = null;
resetEnvWritableMetadataTypes();
}

/**
Expand Down
25 changes: 19 additions & 6 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,22 @@ const OVERLAY_CAPABLE_TYPES: ReadonlySet<string> = new Set(
/**
* Phase 3a-env-writable: parse `OS_METADATA_WRITABLE` (comma-
* separated singular type names). Memoised; tests can reset via
* {@link resetEnvWritableMetadataTypes}. Mirrors the same helper in
* ObjectStackProtocolImplementation — both gates must consult the same
* elevated set so the env-var escape hatch is applied consistently
* regardless of which write path a caller takes.
* {@link resetEnvWritableMetadataTypes}.
*
* [#22411] The ONE reader of the setting. The repository's write gate and
* every protocol consumer of the hatch — `getMetaTypes()`'s listing,
* `isOverlayAllowed()` — ask this function, so the listing cannot advertise a
* hatch the save door then refuses. There used to be a second, hand-copied
* reader in `ObjectStackProtocolImplementation`. The 11.0 removal of
* ObjectStack's own legacy env names (`OBJECTSTACK_METADATA_WRITABLE` →
* `OS_METADATA_WRITABLE`, published as a breaking change) edited this copy and
* missed that one, which went on honouring the removed spelling: the listing
* said "writable", the save answered `403 NOT_CREATABLE`. ⛔ Do not add a
* second reader, and do not give this one a legacy alias back — the spelling
* is removed, and `[]` is that decision.
*/
let _envWritableMetadataTypes: Set<string> | null = null;
function envWritableMetadataTypes(): ReadonlySet<string> {
export function envWritableMetadataTypes(): ReadonlySet<string> {
if (_envWritableMetadataTypes !== null) return _envWritableMetadataTypes;
const raw = readEnvWithDeprecation('OS_METADATA_WRITABLE', []) || '';
const set = new Set<string>();
Expand All @@ -381,7 +390,11 @@ function envWritableMetadataTypes(): ReadonlySet<string> {
return set;
}

/** Test hook — clear the memoised env-writable cache. */
/**
* Test hook — clear the memoised env-writable cache. The one cache
* {@link envWritableMetadataTypes} keeps, so this and
* `ObjectStackProtocolImplementation.resetEnvWritableCache()` clear the same set.
*/
export function resetEnvWritableMetadataTypes(): void {
_envWritableMetadataTypes = null;
}
Expand Down
Loading
Loading