Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/metadata-ambiguous-stem-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/metadata": minor
---

fix(metadata): two files sharing one stem are refused with both paths named, instead of one being listed twice and served by extension precedence (#14921)

**BREAKING** accept-set narrowing on `FilesystemLoader`, shipped as `minor`
under the repo's launch-window convention for breaking changes. Ruled on
#14921 (2026-09-05, option 1 of three).

**Remedy: delete or rename the duplicate file.** The refusal names every
colliding path and the metadata type, so the fix is visible at the point of
failure.

`FilesystemLoader` derives a metadata name by stripping a flat file's
extension, and resolves a name back to a file under a FIXED extension
precedence (`.json` → `.yaml` → `.yml` → `.ts` → `.js`). Two files sharing a
stem therefore produced one name **twice** in `list()` while only the
first-precedence file was reachable through any name at all. With
`object/twin.json` and `object/twin.yaml` both present, `list()` answered
`['twin', 'twin']`, `twin.yaml` was addressable through nothing, and
`loadMany()` returned both bodies. `MetadataManager.listNames()` unions loader
output into a `Set`, which collapsed the duplicate and took the count
discrepancy with it — the file stayed unreachable either way, so a clean
`listNames()` was never evidence the collision had been absorbed.

The invariant that broke: **what is listed is what is loadable.** The listed
set and the addressable set stopped being the same set. The failure was silent
in the direction that matters for authoring — convert `twin.json` to
`twin.yaml` and leave the old file behind, or land one from each of two
packages, and the JSON one is served forever with no diagnostic anywhere,
while `admitLoaderItems()`'s documented "keep the first and say nothing"
absorbs the collision a second time.

`FilesystemLoader.list()` now throws `AmbiguousMetadataStemError`
(`AMBIGUOUS_METADATA_STEM`, HTTP 500) naming both paths and the type, and the
same refusal fronts the shared `loadMany()` / `loadManyKeyed()` walk, so the
two-body answer is gone rather than de-duplicated. `MetadataManager.listNames()`
and `list()` **propagate** it rather than absorbing it into their per-loader
degradation: an ambiguous stem is an authoring error no retry fixes, and
degrading it would drop every item the loader holds into a short-but-served
list while the server keeps reporting healthy. A real storage outage still
degrades exactly as before — the seams discriminate on a branded predicate,
`isAmbiguousMetadataStemError`, not on a blanket rethrow.

**Refused shape**, precisely: two or more files **directly under
`ROOT/TYPE/`** whose basenames differ only by an extension belonging to one of
**this instance's registered serializers**. Register `javascript` and
`dual.json` + `dual.js` becomes ambiguous; under the manager's default format
set (`typescript` / `json` / `yaml`) it is not, because `.js` derives no name.
Nested files are untouched — they are neither listed nor resolvable (#14486),
so `crm/solo.json` beside a flat `solo.json` is not a collision. The refusal is
scoped to the type directory that holds it: a clean `view/` still lists while
`object/` refuses.

New exports from the package root entry: `AmbiguousMetadataStemError`,
`isAmbiguousMetadataStemError`, `AMBIGUOUS_METADATA_STEM_CODE`,
`AMBIGUOUS_METADATA_STEM_STATUS`.

Measured migration cost, which is what makes this narrowing cheap: **no tree in
this repository carries the shape.** A walk of all 7,770 tracked files across
526 directories found zero stem collisions among `.json` / `.yaml` / `.yml` /
`.ts` / `.js`, confirmed independently by a `git ls-files` pass, and the repo
holds no `.yaml`/`.yml` metadata file at all outside CI and workspace config.
No existing tree goes red.

<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing on a LOADER, not on any authorable key: no property of any spec schema is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite in a stored document. The affected artifact is a FILESYSTEM LAYOUT — two sibling files — which the ledger cannot address at all: a migration entry rewrites metadata bodies, and neither of the colliding files is wrong on its own. Which one an author wants kept is intent no entry can decide: they may have meant the conversion to `.yaml` to land and forgotten to delete the `.json`, or may have meant the opposite, and the two files carry no evidence of which. The refusal is the channel that reaches them, at the load site, naming both paths, the type, and the remedy. Measured in-repo population of affected trees is zero: a walk of 7,770 tracked files over 526 directories found no directory holding two files with one stem among the registered extensions, and there are no `.yaml`/`.yml` metadata files outside CI and workspace config. -->
13 changes: 13 additions & 0 deletions packages/metadata/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ export { MemoryLoader } from './loaders/memory-loader.js';
export { RemoteLoader } from './loaders/remote-loader.js';
export { DatabaseLoader, type DatabaseLoaderOptions } from './loaders/database-loader.js';

// [#14921] The ambiguous-stem refusal. Published from the ROOT entry, not only
// from `./node` beside `FilesystemLoader`: the error reaches consumers through
// `MetadataManager.listNames()` / `list()`, which live here, and a caller that
// wants to tell "this deployment's metadata tree names one item twice" apart
// from a storage outage needs the predicate wherever it catches — not only
// where the loader is constructed.
export {
AmbiguousMetadataStemError,
isAmbiguousMetadataStemError,
AMBIGUOUS_METADATA_STEM_CODE,
AMBIGUOUS_METADATA_STEM_STATUS,
} from './loaders/ambiguous-metadata-stem.js';

// Objects
export { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';

Expand Down
118 changes: 118 additions & 0 deletions packages/metadata/src/loaders/ambiguous-metadata-stem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14921] The refusal a metadata source tree earns by naming one item twice.
*
* ## The invariant this restores
*
* *What is listed is what is loadable.* `FilesystemLoader` derives a metadata
* name by stripping the extension from a flat file's basename, and resolves a
* name back to a file under a FIXED extension precedence (`.json` → `.yaml` →
* `.yml` → `.ts` → `.js`). Two files sharing a stem therefore produced one name
* TWICE in `list()` while only the first-precedence file was reachable through
* any name at all: the listed set and the addressable set stopped being the
* same set, and `loadMany()` kept returning both bodies. The loser was
* invisible — not missing, not reported, just never served.
*
* The failure is silent in the direction that matters for authoring, and the
* trigger is a move authors (human and AI) make constantly: convert
* `twin.json` to `twin.yaml` and leave the old file behind, or land one from
* each of two packages. Today the JSON one is served forever with no
* diagnostic anywhere, and `MetadataManager.admitLoaderItems()`'s documented
* "keep the first and say nothing" absorbs the collision a second time.
*
* ## The ruling (maintainer, via the director seat on #14921, 2026-09-05)
*
* Option 1 of three: **refuse the ambiguous stem loudly at list time.** Two
* files sharing a stem across the registered extensions is an AUTHORING ERROR,
* reported with both paths named, never resolved by precedence. Not taken:
* option 2 (keep the precedence and log at `warn` — with zero instances in any
* measured tree, nobody reads that log, and the invariant stays broken) and
* option 3 (make the extension part of the name for the non-first file — a
* naming rule invented for an error state, grown into the contract).
*
* The narrowing is cheap for the reason the grade records: no measured
* production or example tree carries two files with one stem, so no existing
* tree goes red. It is a narrowing with almost no migration account.
*
* ## Why a brand and a predicate rather than bare `instanceof`
*
* `MetadataManager`'s plural reads catch per loader on purpose (#5108/#14423):
* a storage outage must degrade to a short-but-served list rather than take the
* whole enumeration down. This refusal is the opposite kind of fact — an
* author's tree is malformed and no retry fixes it — so those seams have to
* re-raise THIS error while still absorbing every other one. A predicate over
* a `Symbol.for` brand is the discrimination that survives duplicate copies of
* this module in a consumer's dependency graph, where `instanceof` does not.
* Same shape, and for the same reason, as `@objectstack/core`'s
* `isAuthzStoreUnavailableError`.
*/

/** ADR-0112 wire code for the refusal. */
export const AMBIGUOUS_METADATA_STEM_CODE = 'AMBIGUOUS_METADATA_STEM' as const;

/**
* HTTP status a transport should answer.
*
* 500, deliberately: the REQUEST is well formed and no caller can fix it by
* sending something else — the deployment's own metadata source tree is
* ambiguous. Not 503 (nothing is transient here; a retry answers identically
* until a file is deleted or renamed) and not 4xx (the caller did nothing
* wrong).
*/
export const AMBIGUOUS_METADATA_STEM_STATUS = 500 as const;

const AMBIGUOUS_METADATA_STEM_BRAND = Symbol.for('objectstack.metadata.ambiguousStem');

/**
* Thrown when one metadata name is derived from more than one file among a
* loader's REGISTERED extensions.
*
* The message names every colliding path and the metadata type, because those
* are exactly the two things an author needs and neither is recoverable from
* the name alone: a bare "duplicate `twin`" sends them looking through a tree
* for something they already believe they deleted.
*/
export class AmbiguousMetadataStemError extends Error {
/** Brand — see the module doc on why this is not `instanceof`. */
readonly [AMBIGUOUS_METADATA_STEM_BRAND] = true as const;
/** ADR-0112 wire code. */
readonly code = AMBIGUOUS_METADATA_STEM_CODE;
/** HTTP status a transport should answer. */
readonly status = AMBIGUOUS_METADATA_STEM_STATUS;
/** The metadata type whose directory holds the collision (e.g. `object`). */
readonly type: string;
/** The one name both files derive to. */
readonly stem: string;
/** Every colliding file, absolute, sorted — never just the winner. */
readonly paths: readonly string[];

constructor(type: string, stem: string, paths: readonly string[]) {
const sorted = [...paths].sort();
super(
`Ambiguous metadata name \`${stem}\` for type \`${type}\`: ${sorted.length} files ` +
`resolve to the same name — ${sorted.map(p => `\`${p}\``).join(', ')}. ` +
`Only the first would ever be served (extension precedence: .json, .yaml, .yml, .ts, .js), ` +
`so the others are listed and unreachable. Delete or rename all but one.`,
);
this.name = 'AmbiguousMetadataStemError';
this.type = type;
this.stem = stem;
this.paths = sorted;
}
}

/**
* True when `err` is the ambiguous-stem refusal above.
*
* The predicate every catch-and-degrade seam uses to re-raise THIS one without
* loosening its handling of anything else — a storage outage still degrades, an
* author's malformed tree does not.
*/
export function isAmbiguousMetadataStemError(err: unknown): err is AmbiguousMetadataStemError {
return (
typeof err === 'object'
&& err !== null
&& (err as Record<symbol, unknown>)[AMBIGUOUS_METADATA_STEM_BRAND] === true
);
}
Loading
Loading