Skip to content

Commit 3ca71b6

Browse files
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 (#22440)
Fixes #22411 Clause-②: no (narrowing) ## What `OS_METADATA_WRITABLE` now has one reader. `ObjectStackProtocolImplementation.envWritableTypes()` feeds `GET /api/v1/meta/types` and the protocol's write gates (`isOverlayAllowed`: the code-only create gate and the org-scope gate). It now delegates to the repository's `envWritableMetadataTypes()`, which reads `OS_METADATA_WRITABLE` only. `resetEnvWritableCache()` and `resetEnvWritableMetadataTypes()` both clear that reader's single cache. Under the legacy spelling alone, the listing and the save door now agree, because both refuse. ## Why The 11.0 release removed ObjectStack's own legacy env names as a published breaking change. That was fdb41c0 (PR #2383), with the `v11-remove-env-aliases` changeset in the 11.1.0 CHANGELOG of `@objectstack/cli`, `@objectstack/objectql` and `@objectstack/types`, and `docs/upgrading-to-11.md:121`. The change edited the repository's reader and missed the protocol's hand-copied one, which kept honouring `OBJECTSTACK_METADATA_WRITABLE`. Measured on `origin/main` e02833c at the dispatcher `/meta` door, on both kernel shapes, with only `OBJECTSTACK_METADATA_WRITABLE=job` set: - `GET /meta/types` listed `job` with `allowOrgOverride: true` and `overrideSource: env`; - `PUT /meta/job/NEW` answered `403 NOT_CREATABLE` and stored no row. The seat's ruling on the card (comment 6076328784) is option B: finish 11.0's removal at the reader it missed. That ruling supersedes the card's earlier "do not retire the alias on this card" line. Premise re-checked on `b9222dc70` before building: `git grep OBJECTSTACK_METADATA_WRITABLE` finds no non-test producer. The non-test hits are prose (docs, ADR-0010, the S2 changeset) plus the reader itself. The positive control, `OS_AUTH_SECRET` over `docker/`, `examples/` and `apps/`, finds 5 hits. ## Changes - `packages/metadata-protocol/src/sys-metadata-repository.ts`: `envWritableMetadataTypes` is exported at module level and its TSDoc names it the one reader. The package index does not re-export it, so the public surface is unchanged. - `packages/metadata-protocol/src/protocol.ts`: `envWritableTypes` delegates to it, `resetEnvWritableCache` clears the shared cache, and the now-unused `readEnvWithDeprecation` import is removed. - S2's dual-spelling pins keep their preferred-spelling legs. Their legacy legs flip to "the hatch is shut", and each keeps the preferred spelling as its control: - `protocol.packaged-base-refusal.test.ts`: the listing reports `flow` and `page` as `allowOrgOverride: false` with `overrideSource: registry`, and every managed-item verdict equals the shut one. - `packages/rest` `rest-meta-managed-seal-hatch.test.ts`: the same listing reading via `GET /api/v1/meta/types`, and every PUT (and DELETE, on an environment kernel) answers what it answers with nothing set. - `packages/runtime` `meta-managed-content-seal.test.ts`: the card's pins at the dispatcher door. Under the legacy spelling alone, both `job` (the type named) and `picklist` (a type not named) are listed shut and saved `403 NOT_CREATABLE` with no row. Control, `OS_METADATA_WRITABLE=job`: `job` is listed `env` and saved `200` with a row, and `picklist` is still refused. - `content/docs/deployment/environment-variables.mdx`: the alias row goes back into the "Removed in 11" note, which undoes the move made in PR #2640. - `.changeset/22411-hatch-legacy-spelling.md`: **BREAKING** on `@objectstack/metadata-protocol`, graded `minor` on the v18 pre line. It names the FROM and TO spellings and the one-line operator fix, and says it finishes 11.0's removal at the reader that kept it. ADR-0087 disposition: `not-required (no-migration-prescription)`. `check:adr-0087-registration` exits 0. ## Verification (head adbb0a5) - `pnpm --filter @objectstack/metadata-protocol test`: 223 files passed, 3 skipped; 28330 tests passed, 19 skipped. Lock VERDICT command-exit 0. - Typecheck: `pnpm --filter @objectstack/metadata-protocol run typecheck` exits 0. The `rest` and `runtime` `run typecheck` runs also exit 0, and their `check:test-typecheck` passes over each package's `tsconfig.test.json`. - The three edited pin files: runtime 10/10, rest 7/7, metadata-protocol 31/31. - Every other test file in the downstream packages that touches the hatch passes: - objectql: 8 files, 176 tests; - plugin-security: 3 files, 40 tests; - rest: 4 files, 36 tests; - runtime: 3 files, 38 tests. Those suites read `@objectstack/metadata-protocol` through `dist/`, which was rebuilt from this branch first. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 94 commands. All were run, plus the 4 roster gates whose roster sits under a changed directory (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:route-ledger-census`). `--ran` reports 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. Two gates first answered PREREQUISITE NOT MET (exit 3) for unbuilt dists: `check:skill-examples` and `check:dual-build-cjs-loads`. Both were re-run after the builds at the same head, and both exit 0. - Lint: the narrowing is proven, not assumed. `eslint --no-inline-config --format json` over the 5 changed TS files reports 5 files, 0 errors and 0 warnings. eslint's own config ignores the `.md` and `.mdx` ("File ignored because no matching configuration"). The config enables no type-aware linting: no `parserOptions.project` or `projectService`, and 0 typed rules in `--print-config` for `protocol.ts`. So this diff cannot move the verdict on any untouched file. - Not measured locally, owned by CI: the dogfood boots (`managed-content-sealed.dogfood.test.ts` mentions the legacy spelling only in a comment) and the full `rest` and `runtime` suites. ## Reverse verification of the one reader `scripts/ablation-replace.mjs` put the protocol's second, legacy-honouring reader back inside `envWritableTypes`. On disk: anchor 1 to 0, blob `0cf53ab1` to `ef809f30`. - **src, no build:** `protocol.packaged-base-refusal.test.ts` gives 2 failed and 29 passed. The 2 failures are exactly the legacy-shut cases, on both kernels. - **dist, rebuilt:** after rebuilding `@objectstack/metadata-protocol`, `ablation-dist-preflight` finds the marker in `dist/index.js` and `dist/index.cjs`. - runtime: 2 failed, 8 passed. The failures are the card's legacy-alone case on both kernels; under the mutation the listing answered `allowOrgOverride: true` / `env` again. - rest: 2 failed, 5 passed. The failures are the legacy-shut cases. - **Restore:** the blob equals HEAD and `git diff HEAD` is empty. After the rebuild, preflight `--absent` passes on all 24 built files with a clean tree. The green legs come back at 31/31, 10/10 and 7/7. The result went the expected way: the pins turned red. ## Acceptance notes - `docs/adr/0010-metadata-protection-model.md` (`:42`, `:301`, `:540`, `:652`) still describes `OBJECTSTACK_METADATA_WRITABLE` as the live variable. That file is Tier H and pre-11 history, and per the ruling it stays with the seat, outside this code PR. - The changeset states the operator action as a sentence naming FROM, TO and the fix, not as a FROM / TO table row. With a table row, `check:adr-0087-registration` refuses `not-required (no-migration-prescription)` (evidence: header-framed-table). Its closed vocabulary has no category for a deployment-environment rename: - `registered` would put a prescription in the ledger that `os migrate meta` cannot project; - `unpublished` and `already-registered` do not apply; - dropping BREAKING is forbidden. The marker says this in writing, following the precedent of plugin-auth's `OS_PLATFORM_OWNER_EMAIL` changeset. Who takes up that vocabulary gap: nobody yet. - Under the legacy spelling alone, the save door's refusal now comes from the protocol's code-only gate, not the repository's. Code and status are unchanged (`403 NOT_CREATABLE`). Nothing reads the legacy name any more, so its deprecation warning no longer prints. - Left as written because they are still true: the S2 changeset's "(and its legacy spelling …) used to open one", the dogfood header comment, and the `sys-metadata-repository.package-writability.test.ts` legacy case comment ("this reader honours only `OS_METADATA_WRITABLE`"). --- _Generated by [Claude Code](https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b61f2d commit 3ca71b6

7 files changed

Lines changed: 349 additions & 145 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
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
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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. -->
10+
11+
**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`.
12+
13+
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.
14+
15+
**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.
16+
17+
**What does not change.** `OS_METADATA_WRITABLE` behaves exactly as before.
18+
19+
**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.

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ the hosted ObjectOS Cloud control plane.
374374

375375
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_*`.
376376

377-
> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`.
377+
> **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`.
378378
379379
| Canonical | Legacy |
380380
|:---|:---|
@@ -391,6 +391,5 @@ Some env vars accept a legacy alias for compatibility. **Ecosystem-standard name
391391
| `OS_MCP_SERVER_ENABLED` | `MCP_SERVER_ENABLED` |
392392
| `OS_MCP_SERVER_NAME` | `MCP_SERVER_NAME` |
393393
| `OS_MCP_SERVER_TRANSPORT` | `MCP_SERVER_TRANSPORT` |
394-
| `OS_METADATA_WRITABLE` | `OBJECTSTACK_METADATA_WRITABLE` |
395394
| `OS_HOME` | `OBJECTSTACK_HOME` |
396395
| `OS_DEV_CRYPTO_KEY` | `OBJECTSTACK_DEV_CRYPTO_KEY` |

‎packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts‎

Lines changed: 82 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
* deliberately `null` — a name no package ships, a Regime O overlay type,
1616
* and the #6960 delete carve-out. [ADR-0131 D6] The operator hatch is no
1717
* longer one of them: managed content is sealed, so the verdict on an item
18-
* a managed package ships is the same with `OS_METADATA_WRITABLE` (either
19-
* spelling) set or not.
18+
* a managed package ships is the same with `OS_METADATA_WRITABLE` set or
19+
* not. [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE`, which
20+
* 11.0 removed, is not read at all: the hatch is shut under it.
2021
*
21-
* The registry double serves only `getArtifactItem`, which is all the verdict
22-
* reads; what it returns is what the real `SchemaRegistry` returns for an
22+
* The registry double serves `getArtifactItem`, which is all the verdict
23+
* reads, and `getRegisteredTypes`, which the type listing reads to show the
24+
* hatch shut under the removed spelling; what `getArtifactItem` returns is what the real `SchemaRegistry` returns for an
2325
* artifact a code package registered (`_packageId` stamped, package
2426
* provenance). `@objectstack/objectql` cannot be imported here: it depends on
2527
* this package.
@@ -46,7 +48,10 @@ const ARTIFACTS = new Map<string, Map<string, unknown>>([
4648
]);
4749

4850
function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
49-
const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) };
51+
const registry = {
52+
getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name),
53+
getRegisteredTypes: () => Array.from(ARTIFACTS.keys()),
54+
};
5055
return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId);
5156
}
5257

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

123-
// [ADR-0131 D6] Managed content is sealed: the hatch, under either spelling
124-
// its reader honours, opens neither verb on an item a managed package
125-
// ships — the verdict is the one it gives with the hatch shut, sentence
126-
// included. Every door that asks this verdict (the `/automation` doors, the
127-
// read envelope) inherits that.
128-
for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) {
129-
for (const environmentId of [undefined, 'env_1']) {
130-
it(`${variable}=flow,page does not open a managed item (${environmentId ? 'environment' : 'host-config'} kernel)`, () => {
131-
const shut = protocolOn(environmentId);
132-
const sealed = {
133-
save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
134-
delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
135-
page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
136-
};
137-
process.env[variable] = 'flow,page';
138-
ObjectStackProtocolImplementation.resetEnvWritableCache();
139-
const p = protocolOn(environmentId);
140-
const open = {
141-
save: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
142-
delete: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
143-
page: p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
144-
};
145-
for (const verb of ['save', 'delete', 'page'] as const) {
146-
expect(shape(open[verb]), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
147-
expect(open[verb].message, verb).toBe(sealed[verb].message);
148-
}
149-
// The #6960 carve-out and the regime-O overlay keep their `null`.
150-
expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull();
151-
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull();
152-
});
153-
}
128+
// [ADR-0131 D6] Managed content is sealed: the hatch opens neither verb on
129+
// an item a managed package ships — the verdict is the one it gives with
130+
// the hatch shut, sentence included. Every door that asks this verdict (the
131+
// `/automation` doors, the read envelope) inherits that.
132+
for (const environmentId of [undefined, 'env_1']) {
133+
it(`OS_METADATA_WRITABLE=flow,page does not open a managed item (${environmentId ? 'environment' : 'host-config'} kernel)`, () => {
134+
const shut = protocolOn(environmentId);
135+
const sealed = {
136+
save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
137+
delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
138+
page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
139+
};
140+
process.env.OS_METADATA_WRITABLE = 'flow,page';
141+
ObjectStackProtocolImplementation.resetEnvWritableCache();
142+
const p = protocolOn(environmentId);
143+
const open = {
144+
save: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
145+
delete: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
146+
page: p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
147+
};
148+
for (const verb of ['save', 'delete', 'page'] as const) {
149+
expect(shape(open[verb]), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
150+
expect(open[verb].message, verb).toBe(sealed[verb].message);
151+
}
152+
// The #6960 carve-out and the regime-O overlay keep their `null`.
153+
expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull();
154+
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull();
155+
});
156+
}
157+
158+
// [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE` was removed in
159+
// 11.0, and the protocol now reads the setting through the repository's one
160+
// reader, which reads the preferred spelling only. Under the legacy
161+
// spelling alone the hatch is SHUT: the type listing reports no env
162+
// override, and every verdict is the one the shut hatch gives. Control: the
163+
// preferred spelling still opens the listing.
164+
for (const environmentId of [undefined, 'env_1']) {
165+
it(`OBJECTSTACK_METADATA_WRITABLE=flow,page is not read — the hatch is shut (${environmentId ? 'environment' : 'host-config'} kernel)`, async () => {
166+
const shut = protocolOn(environmentId);
167+
const sealed = {
168+
save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
169+
delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
170+
page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
171+
};
172+
const hatchOf = async (p: ObjectStackProtocolImplementation) => {
173+
const { entries } = await (p as any).getMetaTypes();
174+
return Object.fromEntries(['flow', 'page'].map((t) => {
175+
const e = entries.find((x: any) => x.type === t);
176+
return [t, { allowOrgOverride: e?.allowOrgOverride, overrideSource: e?.overrideSource }];
177+
}));
178+
};
179+
const SHUT = { allowOrgOverride: false, overrideSource: 'registry' };
180+
expect(await hatchOf(shut)).toEqual({ flow: SHUT, page: SHUT });
181+
182+
process.env.OBJECTSTACK_METADATA_WRITABLE = 'flow,page';
183+
ObjectStackProtocolImplementation.resetEnvWritableCache();
184+
const legacy = protocolOn(environmentId);
185+
expect(await hatchOf(legacy)).toEqual({ flow: SHUT, page: SHUT });
186+
for (const verb of ['save', 'delete', 'page'] as const) {
187+
const verdict: any = verb === 'page'
188+
? legacy.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' })
189+
: legacy.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: verb });
190+
expect(shape(verdict), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
191+
expect(verdict.message, verb).toBe(sealed[verb].message);
192+
}
193+
194+
// Control: the preferred spelling is read, so the same names open the listing.
195+
process.env.OS_METADATA_WRITABLE = 'flow,page';
196+
ObjectStackProtocolImplementation.resetEnvWritableCache();
197+
const OPEN = { allowOrgOverride: true, overrideSource: 'env' };
198+
expect(await hatchOf(protocolOn(environmentId))).toEqual({ flow: OPEN, page: OPEN });
199+
});
154200
}
155201
});
156202

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
DataProtocol, MetadataProtocol, PackageProtocol,
55
} from '@objectstack/spec/api';
66
import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core';
7-
import { declaredUserMessage, readEnvWithDeprecation, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
7+
import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
88
// [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?".
99
// `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering
1010
// this question with it is a bug (cloud#1020, #5233) — so the posture, and only
@@ -43,7 +43,14 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
4343
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
4444
import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js';
4545
import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js';
46-
import { DraftConflictError, SysMetadataRepository, packageScopedRowWhere, type SysMetadataEngine } from './sys-metadata-repository.js';
46+
import {
47+
DraftConflictError,
48+
SysMetadataRepository,
49+
envWritableMetadataTypes,
50+
packageScopedRowWhere,
51+
resetEnvWritableMetadataTypes,
52+
type SysMetadataEngine,
53+
} from './sys-metadata-repository.js';
4754
import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js';
4855
import {
4956
resolveArtifactLockLayer,
@@ -15860,29 +15867,24 @@ export class ObjectStackProtocolImplementation implements
1586015867
* use to enable Studio-side editing of types whose protocol-level flag
1586115868
* is still false (object, field, permission, …).
1586215869
*
15863-
* Memoised at first call. Tests can override by clearing the cache via
15870+
* [#22411] Delegates to the repository's {@link envWritableMetadataTypes},
15871+
* the ONE reader of the setting, so the listing and the save door read the
15872+
* same set. This used to be a second copy of the parse. The 11.0 removal of
15873+
* the legacy `OBJECTSTACK_METADATA_WRITABLE` spelling edited the other
15874+
* copy and missed this one, so the listing advertised a hatch that the
15875+
* repository then refused. ⛔ Never parse the variable here again.
15876+
*
15877+
* Memoised at first call (by the shared reader). Tests can override by
15878+
* clearing the cache via
1586415879
* {@link ObjectStackProtocolImplementation.resetEnvWritableCache}.
1586515880
*/
15866-
private static _envWritableTypes: Set<string> | null = null;
1586715881
private static envWritableTypes(): ReadonlySet<string> {
15868-
if (this._envWritableTypes !== null) return this._envWritableTypes;
15869-
const raw = readEnvWithDeprecation('OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE') || '';
15870-
const set = new Set<string>();
15871-
for (const tok of raw.split(',')) {
15872-
const t = tok.trim();
15873-
if (!t) continue;
15874-
const singular = PLURAL_TO_SINGULAR[t] ?? t;
15875-
set.add(singular);
15876-
const plural = SINGULAR_TO_PLURAL[singular];
15877-
if (plural) set.add(plural);
15878-
}
15879-
this._envWritableTypes = set;
15880-
return set;
15881-
}
15882-
15883-
/** Test hook — clear the memoised env-writable cache. */
15882+
return envWritableMetadataTypes();
15883+
}
15884+
15885+
/** Test hook — clear the memoised env-writable cache (the shared reader's one cache). */
1588415886
static resetEnvWritableCache(): void {
15885-
this._envWritableTypes = null;
15887+
resetEnvWritableMetadataTypes();
1588615888
}
1588715889

1588815890
/**

‎packages/metadata-protocol/src/sys-metadata-repository.ts‎

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

384-
/** Test hook — clear the memoised env-writable cache. */
393+
/**
394+
* Test hook — clear the memoised env-writable cache. The one cache
395+
* {@link envWritableMetadataTypes} keeps, so this and
396+
* `ObjectStackProtocolImplementation.resetEnvWritableCache()` clear the same set.
397+
*/
385398
export function resetEnvWritableMetadataTypes(): void {
386399
_envWritableMetadataTypes = null;
387400
}

0 commit comments

Comments
 (0)