Skip to content

fix(spec): strict defineStack refuses a Set / Map for a map-form collection instead of accepting it as empty - #19811

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19796-map-form-non-plain-object
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19796-map-form-non-plain-object

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19796

Clause-②: no (narrowing)

normalizeMetadataCollection now reads only a plain object as the map form of a collection. Before, its map branch tested typeof value === 'object', so a Set, Map or Date went through Object.entries (which yields an empty list for them) before the schema parse, and strict defineStack accepted the stack with the collection empty. Every authored entry was gone, with no error and no warning. Now a non-plain object reaches the parse unchanged, and the ordinary strict envelope refuses it at the key.

Direction: refuse, not convert. Converting an iterable of entries would widen the strict door's accept set, and the triage condition (5790091608) put that out of scope. The governing sentence is ruling 5690859601 on #18239: 「A composed artifact is complete or it is refused」.

Landing site

packages/spec/src/shared/metadata-collection.zod.ts: a module-local isPlainObject guards the map branch. It treats a value as plain when its prototype is null, or when its prototype's own prototype is null. The second case accepts a plain object from another realm (a vm context), whose prototype is that realm's Object.prototype. This is the only producer, so no other package is touched.

Measurement (tsx probe, strict defineStack({ manifest, permissions: VALUE }))

VALUE base 0b83e01627 this branch
new Set([{ name: 'rep', label: 'Rep' }]) ACCEPTED, permissions is [] STACK_SCHEMA_INVALID 422, issue invalid_type at ['permissions'], expected: 'array'
new Map([['rep', { label: 'Rep' }]]) ACCEPTED, [] same refusal
new Date() ACCEPTED, [] same refusal
a class instance with a rep field read as a map of its own fields (then refused deeper, at permissions.0.objects) same refusal, at the key
Object.create(null) map with rep normalized, as a map unchanged (normalized)

The refusal message is permissions: Expected array but received object., and it names the key where the value was written. No new error code and no prescription text: see the Acceptance notes.

Callers of normalizeMetadataCollection

In spec it is called only through normalizeStackInput and normalizePluginMetadata, in the same file. normalizePluginMetadata has no callers outside packages/spec. normalizeStackInput has these callers outside the spec package:

  • Callers that parse: defineStack (strict), CLI compile, validate, lint, lint/score, utils/scaffold-validate, migrate meta. A non-plain object used to reach the parse as [] and pass. Now it is refused at the key.
  • defineStack({ strict: false }): the value flows through unparsed. composeStacks step 3 then refuses it (landed in fix(spec): composeStacks refuses a non-array concatenated collection instead of dropping its content #19794), where before a [] composed silently.
  • Readers that do not parse: CLI info, doctor, i18n check, i18n extract, the dogfood build-shaped-artifact helper. They now see the original Set / Map instead of []. They guard with Array.isArray or count through resolveStackCollection, so the content is not counted either before or after. Nothing is widened.

Tests

New packages/spec/src/shared/metadata-collection-non-plain-object.test.ts:

  • Unit tests: normalizeMetadataCollection returns a Set, Map, Date or class instance unchanged (same reference). Controls: an object literal, a null-prototype object and a cross-realm plain object are still normalized.
  • Envelope tests: for every key in MAP_SUPPORTED_FIELDS (derived from the list, 21 keys) and each of the four non-plain shapes, strict defineStack refuses with code: 'STACK_SCHEMA_INVALID' and status: 422, plus a zod issue invalid_type at [key] with expected: 'array'. Control: permissions written as a plain-object map is accepted with its entry.

Runs at head a4c6b1c5ad:

  • targeted: vitest run --project local on the new file plus metadata-collection.test.ts: 2 files, 138 tests passed.
  • full spec suite: pnpm --filter @objectstack/spec exec vitest run --project local: 519 files, 15309 passed, 1 todo.
  • pnpm --filter @objectstack/spec typecheck: exit 0. build then check:generated: all 15 generated artifacts up to date.
  • Ablation (fix committed first, through scripts/ablation-replace.mjs): the anchor if (isPlainObject(value)) { was replaced by if (typeof value === 'object') {. The anchor count went 1 to 0, the replacement count 0 to 1, and the blob f5f0f9d131 became 88a88c8d96. Result: 88 failed / 50 passed. The 88 failures are 84 envelope cases (21 keys times 4 shapes) and 4 unit cases, and every control stayed green. Restore: the blob equals HEAD and git diff HEAD is empty. The test imports src/ directly, so no dist leg is involved.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran reports 83 derived, 81 run with exit 0, 2 NOT MEASURED. Both are check:dual-build-cjs-loads and check:type-check-debt, which exited 3 with PREREQUISITE NOT MET: they need the whole workspace built, and CI runs them. check:doc-formula-expressions and check:lean-entry-closure first exited 3; they went green after building formula, lint and objectql.
  • eslint, narrowed to the 2 changed files (--no-inline-config --format json): 2 files, 0 errors, 0 warnings. eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot change the verdict for any untouched file. The full pnpm lint run is CI's.

Changeset

.changeset/19796-map-form-non-plain-object.md: minor, BREAKING, ADR-0087 not-required (no-migration-prescription). A Set, Map or class instance is not a serializable metadata document, so there is nothing for migrate meta to rewrite.

Acceptance notes

  • The refusal text is the error map's generic Expected array but received object. A Set-specific prescription (for example, naming the constructor) would change shared/error-map.zod.ts, which is outside this card's file surface, so it is not done here. The key and the envelope are already exact.
  • normalizePluginMetadata: when the alias value (for example triggers) is not an array after normalization, the alias key is deleted without merging. This is true both before and after this change, and for any non-collection value. The function has no callers outside packages/spec (consumer: none). This is an observation only, and no card is filed.
  • A class instance used as a map is no longer read as a map of its own fields. This is part of the same narrowing: only a plain object is the declared map form (MetadataCollectionInput is T[] | Record).

Generated by Claude Code

…he map form

A Set, Map, Date or class instance is no longer passed to Object.entries
(which yields [] for a Set or Map) before the strict parse; it reaches the
parse unchanged and is refused at the key.

Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6bfe0113affc0e44b27909ed59ddffa7262f66c4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 87ddcdbd2aa200f7ee7740a4e4bc5b0422c6068f — the merge of head a4c6b1c5ad7ba8f9cd202f8ffcc7bfafdb136653 into base 6bfe0113affc0e44b27909ed59ddffa7262f66c4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 87ddcdbd2aa200f7ee7740a4e4bc5b0422c6068f && git checkout 87ddcdbd2aa200f7ee7740a4e4bc5b0422c6068f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6bfe0113affc0e44b27909ed59ddffa7262f66c4 a4c6b1c5ad7ba8f9cd202f8ffcc7bfafdb136653 && git checkout -B drift-repro 6bfe0113affc0e44b27909ed59ddffa7262f66c4 && git merge --no-ff a4c6b1c5ad7ba8f9cd202f8ffcc7bfafdb136653

node scripts/docs-audit/affected-docs.mjs --json 6bfe0113affc0e44b27909ed59ddffa7262f66c4

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a4c6b1c5ad7ba8f9cd202f8ffcc7bfafdb136653

① Derived judgments

  • Predicate isPlainObject = prototype null, or prototype's prototype null (metadata-collection.zod.ts:125-126, guard :173 — seat re-read). Probed at head and on a clean merge with origin/main e7a8bd43a4: literal, JSON.parse, structuredClone, Object.freeze, Proxy of a literal, Object.create(null), a vm other-realm object, Object.create(Object.prototype) — all still normalized (no false negatives among realistic author spellings). Refused: Set, Map, Date, class instance, Object.create(Set.prototype) / (Array.prototype) / ({}). The only theoretical false positives were already read as maps before ⇒ the accept set did not widen anywhere; pure narrowing. Direction REFUSE, ⛔ not convert — triage condition 5790091608 met.
  • Producers: git grep over packages / examples / templates / apps / scripts — zero stack-collection producers using Set / Map / class instances / Object.create / Proxy; no real producer breaks.
  • Callers: every parsing caller (defineStack, CLI compile / validate / lint / lint score / scaffold-validate / migrate meta) now refuses at the key with STACK_SCHEMA_INVALID / 422, invalid_type at [key], expected 'array' (21 map-form keys × 4 shapes pinned; 138/138 at head and on the merged tree); applyConversions guards every collection with Array.isArray, so nothing throws before the parse. defineStack({ strict: false }) + objects: Set would be a bare TypeError at the PR's own base, but on current origin/main (fix(spec): defineStack strict:false refuses a non-array objects with an ADR-0112 envelope #19798 merged) it is refused with the envelope — measured on the merged tree; the PR body's 「composeStacks step 3 refuses it」 is merely stale about which door. Non-parsing readers (info, doctor, i18n check / extract, dogfood) are Array.isArray-gated ⇒ nothing new counted, no new crash.
  • ⛔ No new code; no accept-set widening.

② Semver level

minor + BREAKING + adr-0087: not-required (no-migration-prescription) + Clause-②: no (narrowing) — the family shape; check-adr-0087-registration exit 0; changeset prose reproduced by the probe.

③ Boundary flags

  • Scope within the card (one producer file, one test, one changeset).
  • Acceptance note 1 (generic 「Expected array but received object」 text) — UX nicety, not a defect.
  • Acceptance note 2: normalizePluginMetadata (a public root export) still deletes an alias value it cannot merge — a triggers: new Set(…) now vanishes with no refusal, one function down. Outside this card's surface, non-blocking.

Implemented-by: claude/issue-19796-map-form-non-plain-object
Reviewed-by: session_01VWsFyWDp8Rjb2Ma6a3Cyo8

VERDICT: PASS

Rendered by an isolated at-tier review subagent, adopted by domain:spec seat 4 after re-reading the predicate and the alias branch on the head / main. Checks at review time: 32 success, 3 skips, 0 failed.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 08:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 0e06f3b Sep 23, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19796-map-form-non-plain-object branch September 23, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant