fix(spec): strict defineStack refuses a Set / Map for a map-form collection instead of accepting it as empty - #19811
Conversation
…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>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 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
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered by an isolated at-tier review subagent, adopted by Generated by Claude Code |
Fixes #19796
Clause-②: no (narrowing)
normalizeMetadataCollectionnow reads only a plain object as the map form of a collection. Before, its map branch testedtypeof value === 'object', so aSet,MaporDatewent throughObject.entries(which yields an empty list for them) before the schema parse, and strictdefineStackaccepted 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 ruling5690859601on #18239: 「A composed artifact is complete or it is refused」.Landing site
packages/spec/src/shared/metadata-collection.zod.ts: a module-localisPlainObjectguards the map branch. It treats a value as plain when its prototype isnull, or when its prototype's own prototype isnull. The second case accepts a plain object from another realm (avmcontext), whose prototype is that realm'sObject.prototype. This is the only producer, so no other package is touched.Measurement (tsx probe, strict
defineStack({ manifest, permissions: VALUE }))0b83e01627new Set([{ name: 'rep', label: 'Rep' }])permissionsis[]STACK_SCHEMA_INVALID422, issueinvalid_typeat['permissions'],expected: 'array'new Map([['rep', { label: 'Rep' }]])[]new Date()[]repfieldpermissions.0.objects)Object.create(null)map withrepThe 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
normalizeMetadataCollectionIn spec it is called only through
normalizeStackInputandnormalizePluginMetadata, in the same file.normalizePluginMetadatahas no callers outsidepackages/spec.normalizeStackInputhas these callers outside the spec package:defineStack(strict), CLIcompile,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.composeStacksstep 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.info,doctor,i18n check,i18n extract, the dogfoodbuild-shaped-artifacthelper. They now see the originalSet/Mapinstead of[]. They guard withArray.isArrayor count throughresolveStackCollection, 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:normalizeMetadataCollectionreturns 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.MAP_SUPPORTED_FIELDS(derived from the list, 21 keys) and each of the four non-plain shapes, strictdefineStackrefuses withcode: 'STACK_SCHEMA_INVALID'andstatus: 422, plus a zod issueinvalid_typeat[key]withexpected: 'array'. Control:permissionswritten as a plain-object map is accepted with its entry.Runs at head
a4c6b1c5ad:vitest run --project localon the new file plusmetadata-collection.test.ts: 2 files, 138 tests passed.pnpm --filter @objectstack/spec exec vitest run --project local: 519 files, 15309 passed, 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0.buildthencheck:generated: all 15 generated artifacts up to date.scripts/ablation-replace.mjs): the anchorif (isPlainObject(value)) {was replaced byif (typeof value === 'object') {. The anchor count went 1 to 0, the replacement count 0 to 1, and the blobf5f0f9d131became88a88c8d96. 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 andgit diff HEADis empty. The test importssrc/directly, so nodistleg is involved.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranreports 83 derived, 81 run with exit 0, 2 NOT MEASURED. Both arecheck:dual-build-cjs-loadsandcheck:type-check-debt, which exited 3 with PREREQUISITE NOT MET: they need the whole workspace built, and CI runs them.check:doc-formula-expressionsandcheck:lean-entry-closurefirst exited 3; they went green after buildingformula,lintandobjectql.--no-inline-config --format json): 2 files, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change the verdict for any untouched file. The fullpnpm lintrun is CI's.Changeset
.changeset/19796-map-form-non-plain-object.md:minor, BREAKING, ADR-0087not-required (no-migration-prescription). ASet,Mapor class instance is not a serializable metadata document, so there is nothing formigrate metato rewrite.Acceptance notes
Expected array but received object.A Set-specific prescription (for example, naming the constructor) would changeshared/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 exampletriggers) 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 outsidepackages/spec(consumer: none). This is an observation only, and no card is filed.MetadataCollectionInputisT[] | Record).Generated by Claude Code