fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope - #19783
Conversation
…ore the guard) Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
… envelope mergeObjects iterated stack.objects unguarded: a truthy non-iterable raised a bare TypeError, a falsy non-array was skipped in silence, and a non-array iterable composed as if it were an array. It now refuses every non-array objects with STACK_SCHEMA_INVALID (status 422, the zod issue on issues with path objects), the code the strict parse raises for the same defect. A non-object entry inside an array objects is skipped and reported through the shared malformed-collection warning, and the artifact pass's object-name collector skips it too instead of dereferencing it. 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 Check3 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 aa11e97468317d2b337213ace2dc9eab7555bebc && git checkout aa11e97468317d2b337213ace2dc9eab7555bebc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43e17b89053495cd30a7a2e86809e6772dcb64c4 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1 && git checkout -B drift-repro 43e17b89053495cd30a7a2e86809e6772dcb64c4 && git merge --no-ff 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1
node scripts/docs-audit/affected-docs.mjs --json 43e17b89053495cd30a7a2e86809e6772dcb64c4 |
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered by an isolated at-tier review subagent (fed the card, the ruling, the PR and the head's check-runs — not the dispatch order), adopted by Generated by Claude Code |
Fixes #18239
Clause-②: no (narrowing)
Implements ruling
5690859601(batch #139 item 2, letter B):composeStacksstep 2 (mergeObjects) refuses a stack whoseobjectsis not an array, with an ADR-0112 envelope. It no longer raises a bareTypeError, and it no longer skips the stack. A non-object entry inside an arrayobjectsis skipped and reported throughwarnMalformedCollectionKey, the way step 3 handles a malformed collection.What changed
packages/spec/src/stack.zod.ts, three hunks, none of them inside the regions open PR #19666 edits (ObjectStackDefinitionSchemaat ~1408,ComposeStacksOptionsSchema,preservePackageEntries, thecomposeStacksdocblock and body). I read that PR's hunk list before editing.mergeObjects, the ruled change. Ifobjectsisundefined, the key is absent and the stack is skipped as before. Any other non-array value throwsStackSchemaInvalidError:code: 'STACK_SCHEMA_INVALID',status: 422.issuescarries one real zod issue (z.array(z.unknown()).safeParseof the value, withpathprefixed['objects'], socode: 'invalid_type'andexpected: 'array'). The message startscomposeStacks validation failed:, names the stack by manifest id and position, and names the key'objects'. A non-object entry is skipped and reported throughwarnMalformedCollectionKey('objects', 'entry').warnMalformedCollectionKeygains an optionalshapeargument. The default'value'path is unchanged byte for byte. The new'entry'path prints an accurate sentence ("an entry in it that is not an object"), because the existing sentence says "a non-array value", which is false for an entry. The two shapes are deduplicated separately.collectObjectNamesskips a non-object entry. Without this, step 3b (collectArtifactCrossReferenceErrors) reads the raw input'sobjectsand still raisesTypeError: Cannot read properties of null (reading 'name')on an entry that step 2 had just skipped. The ruling's entry half cannot hold end to end without it. This is one line outside themergeObjectsbody that the claim fenced: same file, same defect class, not in feat(spec)!: a multi-package artifact carries its metadata once, in packages[] (#14512) #19666's hunks.Why
STACK_SCHEMA_INVALIDand not a new code. The ruling asks for a closederror.code. The strictdefineStackparse already refuses this exact authored mistake withSTACK_SCHEMA_INVALID(anumberinobjectsraises it; the new test pins that). Reusing it gives one code for one defect, whichever door catches it. The header names the pass and the code names the rule, the same splitSTACK_CROSS_REFERENCE_INVALIDalready makes across itsdefineStackandcomposeStacksraise sites. It also adds no ledger row and noErrorCodemember, which keeps the claim'sClause-②: notrue: the public face does not grow, only the accept set narrows. ASTACK_COMPOSE_*spelling would also be wrong by that family's own docblock, which reserves it for disagreements between stacks.Red before the guard, green after
The fixture is
packages/spec/src/compose-stacks-objects-shape-refusal.test.ts(25 cases). The subject is imported from./stack.zod(source, notdist), so the ablation needs no rebuild.22c2465fbefore the fix existed):Tests 23 failed | 2 passed (25).862bc7bb):Tests 25 passed (25).node scripts/ablation-replace.mjs. It swapped the anchorif (!Array.isArray(declared)) {for the base'sif (!declared) continue;behaviour (anchor 1 to 0, blob66d0db3fe4ccto486f0a54245a). Result:Tests 21 failed | 4 passed (25). The 21 are exactly the per-row refusal cases. The tool then restored the file: blob equals HEAD66d0db3fe4cc,git diff HEADempty.What the same composition (a well-formed stack plus a second stack whose
objectsis X) did at the ablated state compared with the fix, from a direct probe:5TypeError("… is not iterable"),codeandstatusundefinedSTACK_SCHEMA_INVALID, 422null,'',0,false["a_item"](the stack's objects silently absent)STACK_SCHEMA_INVALID, 422Setof objectsSTACK_SCHEMA_INVALID, 422The
strict: falsepath is exercised: four rows go throughdefineStack(config, { strict: false }).The ruling's measurement: does skipping a malformed
permissions/datachange the composed artifact's content?Yes, for a non-array value; no, for a non-object entry. Probed at HEAD. Stack A has
permissions: [{ name: 'pa' }], and stack B has a hand-built non-arraypermissions: { name: 'pb', … }. The result iscomposed.permissions = ["pa"]: B's grant is absent from the artifact, and only the #5005 warning mentions it.databehaves the same way (composed.data = ["a_item"], B's dataset absent). The loss happens in step 3, the concat pass (CONCAT_ARRAY_FIELDS), not in step 3b's collectors: #18212's skip there only affects validation. A non-object entry (permissions: [null, {…}]) is carried into the artifact as is ([null, {"name":"pb",…}]), so its content is unchanged. The ruling says this answer goes back to the card for the spec lane to file C. I have not widened anything here.Verification
Run at HEAD
927ea9bfa6. That commit only adds the changeset on top of862bc7bb, so no source changed after the test runs. Heavy runs went throughscripts/pm/os-verify-lock.sh, and each result below is itsVERDICT command-exitline.pnpm --filter @objectstack/spec test+typecheck(which includescheck:test-typecheckover the test layer): exit 0.Test Files 516 passed | 1 skipped (517),Tests 15079 passed | 1 skipped | 1 todo, andcheck:test-typecheck: OK — 53 file(s) / 257 error(s) / 142 pinned signature(s) held. The new test file compiles with no new debt.pnpm --filter @objectstack/spec build, thencheck:generated: exit 0,All 15 generated artifacts are up to date.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 82 commands. All were run and exit codes recorded before any pipe.--ranreconciliation:82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED.check:dual-build-cjs-loadsandcheck:type-check-debt. Both exit 3 (PREREQUISITE NOT MET) because they need every workspace package'sdist, which means a fullpnpm build. That is left to CI.check-plugin-teardown-shape --self-test(fetched its pinned control commit),check:doc-formula-expressions(built formula and lint),check:lean-entry-closure(built objectql).check-adr-0087-registration: the changeset is detected as[BREAKING+clause-②-narrowing]with dispositionnot-required (no-migration-prescription).eslint --no-inline-config --format jsonon the 2 changed.tsfiles gives files 2, errors 0, warnings 0.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change any verdict on an untouched file. The repo-widepnpm lintis CI's.Changeset
.changeset/18239-merge-objects-refusal.md:minor, with the BREAKING banner (a public root export now refuses a class of input), a before/after table, theClause-②: no (narrowing)line, and the ADR-0087 dispositionnot-required (no-migration-prescription). No authorable key, export or stored shape moves, and the strict parse already refused every input this refuses.Acceptance notes
composeStacks([oneStack])returnsstacks[0]untouched, so a single input is never refused here. This is the same declared boundary the artifact cross-reference pass states.defineStack(config, { strict: false })withobjectsset to aSetreturns without throwing. The map-form normalizer reads the Set as a map with no keys. Contrived, and no author writes it.strict: falsedoor crashes before composition is reached, inmergeActionsIntoObjects.defineStack({ …, objects: 5 }, { strict: false })givesTypeError: config.objects.map is not a function.objects: [null, …]givesTypeError: Cannot read properties of null (reading 'actions'). Both havecodeandstatusundefined. It is the same family, but a different function and a different door, so it is out of this card's scope.Generated by Claude Code