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
26 changes: 26 additions & 0 deletions .changeset/19796-map-form-non-plain-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': minor
---

fix(spec): strict `defineStack` refuses a `Set`, `Map` or other non-plain object for a map-form collection key instead of accepting it as an empty collection

**BREAKING** — `defineStack` (strict, the default) now refuses a class of input it used to accept with every authored entry silently missing.

`normalizeMetadataCollection` turns the map form of a collection (`permissions: { rep: { … } }`) into an array before the schema parse. It read any `typeof 'object'` value as that map form, so a `Set`, a `Map` or a `Date` went through `Object.entries`, which yields `[]` for them. The parse then saw a valid empty array: `defineStack({ manifest, permissions: new Set([{ name: 'rep', … }]) })` was accepted with `permissions: []` — the author's grants gone, no error, no warning. This reached every map-form key (every entry of `MAP_SUPPORTED_FIELDS`: `objects`, `apps`, `permissions`, `flows`, `agents`, …).

| the key's value | before | after |
| :--- | :--- | :--- |
| a `Set`, a `Map`, a `Date` | accepted, the collection is `[]` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, zod issue at the key (`expected: 'array'`) |
| a class instance | read as a map of its own fields | refused the same way |
| an object literal, `Object.create(null)`, a plain object from another realm | normalized (key → `name`) | unchanged |
| an array | passed through | unchanged |

Only a plain object is the map form; every other value reaches the parse unchanged and is refused there, at the key where it was written. `normalizeMetadataCollection`, `normalizeStackInput` and `normalizePluginMetadata` (public `@objectstack/spec` exports) now return such a value unchanged instead of `[]`.

The one-line fix: author the key as an array (`[...set]`, `[...map.values()]`) or as a plain-object map (`Object.fromEntries(map)`).

No code is added to the ADR-0112 ledger and no export changes: the refusal is the strict parse's existing `STACK_SCHEMA_INVALID`.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, Zod schema, export, config field or stored metadata shape is added, removed, renamed or re-spelled. A Set, Map or class instance is not a serializable metadata document, so no stored or authored source file carries one and objectstack migrate meta has nothing to rewrite; what narrows is the normalizer's reading of in-memory values that the map form never declared. -->

Clause-②: no (narrowing)
111 changes: 111 additions & 0 deletions packages/spec/src/shared/metadata-collection-non-plain-object.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `normalizeMetadataCollection` reads ONLY a plain object as the map form.
*
* ## What was wrong
*
* The map-form branch tested `typeof value === 'object'`, so a `Set`, a `Map`
* or a `Date` was read as a map too. `Object.entries` of any of them is `[]`,
* and normalization runs before the schema parse, so the strict `defineStack`
* door saw a valid empty array and ACCEPTED the stack with every authored
* entry (e.g. its permission-set grants) gone.
*
* ## What is pinned
*
* A composed artifact is complete or it is refused. For every key that accepts
* the map form (derived from `MAP_SUPPORTED_FIELDS`, never transcribed), a
* non-plain object reaches the strict parse unchanged and is refused with the
* ordinary strict envelope: `STACK_SCHEMA_INVALID`, `status: 422`, a zod issue
* rooted at the key expecting an array. The controls: the same key authored as
* a plain-object map — a literal, a null-prototype object, and a plain object
* from another realm — is still normalized.
*/
import { describe, it, expect } from 'vitest';
import { runInNewContext } from 'node:vm';
import { normalizeMetadataCollection, MAP_SUPPORTED_FIELDS } from './metadata-collection.zod';
import { defineStack } from '../stack.zod';

type Envelope = Error & {
code?: string;
status?: number;
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>;
};

/** The thrown value, or `null` when the stack is accepted. */
function refusal(fn: () => unknown): Envelope | null {
try {
fn();
return null;
} catch (e) {
return e as Envelope;
}
}

const manifest = { id: 'com.example.a', name: 'a', version: '1.0.0', type: 'app' as const };

class Holder {
rep = { label: 'Rep' };
}

const NON_PLAIN: ReadonlyArray<readonly [string, () => unknown]> = [
['a Set', () => new Set([{ name: 'rep', label: 'Rep' }])],
['a Map', () => new Map([['rep', { label: 'Rep' }]])],
['a Date', () => new Date(0)],
['a class instance', () => new Holder()],
];

describe('normalizeMetadataCollection — only a plain object is the map form', () => {
for (const [label, make] of NON_PLAIN) {
it(`${label} is returned unchanged, never read as an empty map`, () => {
const value = make();
expect(normalizeMetadataCollection(value)).toBe(value);
});
}

it('control: an object literal is normalized with key → name', () => {
expect(normalizeMetadataCollection({ rep: { label: 'Rep' } })).toEqual([{ name: 'rep', label: 'Rep' }]);
});

it('control: a null-prototype object is normalized with key → name', () => {
const map = Object.assign(Object.create(null), { rep: { label: 'Rep' } });
expect(normalizeMetadataCollection(map)).toEqual([{ name: 'rep', label: 'Rep' }]);
});

it('control: a plain object from another realm is normalized with key → name', () => {
const map = runInNewContext('({ rep: { label: "Rep" } })');
expect(Object.getPrototypeOf(map)).not.toBe(Object.prototype);
expect(normalizeMetadataCollection(map)).toEqual([{ name: 'rep', label: 'Rep' }]);
});
});

describe('strict defineStack refuses a non-plain object for a map-form collection key', () => {
it('covers every map-form key the normalizer declares (the list is the census)', () => {
expect(MAP_SUPPORTED_FIELDS).toContain('permissions');
expect(MAP_SUPPORTED_FIELDS.length).toBeGreaterThanOrEqual(20);
});

for (const key of MAP_SUPPORTED_FIELDS) {
for (const [label, make] of NON_PLAIN) {
it(`'${key}': ${label} is refused with STACK_SCHEMA_INVALID / 422 at the key — never accepted as []`, () => {
const err = refusal(() => defineStack({ manifest, [key]: make() } as never));
expect(err, `'${key}' given ${label} was accepted`).not.toBeNull();
expect(err!.code).toBe('STACK_SCHEMA_INVALID');
expect(err!.status).toBe(422);
expect(err!.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_type', path: [key], expected: 'array' }),
]),
);
});
}
}

it("control: 'permissions' authored as a plain-object map is accepted with its entry", () => {
const stack = defineStack({
manifest,
permissions: { rep: { label: 'Rep', objects: {} } },
} as never);
expect(stack.permissions?.map((p) => p.name)).toEqual(['rep']);
});
});
25 changes: 23 additions & 2 deletions packages/spec/src/shared/metadata-collection.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,26 @@ export {
singularToPlural,
} from '../meta-spelling/manifest-collection-spelling.js';

/**
* Whether `value` is a plain object — a `{ … }` literal, `Object.create(null)`,
* or a plain object from another realm (a `vm` context): its prototype is
* `null`, or a prototype whose own prototype is `null` (that realm's
* `Object.prototype`). A `Set`, `Map`, `Date`, array or class instance is not.
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') return false;
const proto: unknown = Object.getPrototypeOf(value);
return proto === null || Object.getPrototypeOf(proto) === null;
}

/**
* Normalize a single metadata collection value from map format to array format.
* If the input is already an array (or nullish), it is returned unchanged.
* If the input is a plain object (map), it is converted to an array where
* each key is injected as the `name` field of the corresponding item.
* Any other value — including a non-plain object such as a `Set` or `Map` — is
* returned unchanged, so schema validation refuses it rather than this
* function reading it as an empty map.
*
* **Precedence:** If an item already has a `name` property, it is preserved
* (the map key is only used as a fallback).
Expand Down Expand Up @@ -148,8 +163,14 @@ export function normalizeMetadataCollection(value: unknown, keyField = 'name'):
// Nullish or already an array — pass through
if (value == null || Array.isArray(value)) return value;

// Plain object — treat as map and convert to array
if (typeof value === 'object') {
// Plain object — treat as map and convert to array. ONLY a plain object: a
// `Set`, `Map`, `Date` or class instance is `typeof 'object'` too, and
// `Object.entries` reads its own enumerable string keys — `[]` for a `Set` or
// a `Map` — so treating it as the map form would hand the parse a valid empty
// array and drop every authored entry before any schema saw it. A non-plain
// object falls through unchanged so the strict parse refuses it as a
// non-array at the key, where it was written.
if (isPlainObject(value)) {
return Object.entries(value as Record<string, unknown>).map(([key, item]) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const obj = item as Record<string, unknown>;
Expand Down
Loading