fix(spec): composeStacks refuses a non-array concatenated collection instead of dropping its content - #19794
Conversation
…instead of dropping it Co-authored-by: Claude <noreply@anthropic.com> 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> Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8
…ncat-fields-refusal
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 2f0ef75a5793deb4e27d4026fdb668d10497b6f5 && git checkout 2f0ef75a5793deb4e27d4026fdb668d10497b6f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fae870352ea59ddfb1c6dfd784bc6552cf158211 0bf2e5564619cd3e4d5b36541c66850205ffa2a5 && git checkout -B drift-repro fae870352ea59ddfb1c6dfd784bc6552cf158211 && git merge --no-ff 0bf2e5564619cd3e4d5b36541c66850205ffa2a5
node scripts/docs-audit/affected-docs.mjs --json fae870352ea59ddfb1c6dfd784bc6552cf158211
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered by an isolated at-tier review subagent (card, ruling, precedent PR, the PR and its check-runs — not the dispatch order), adopted by Generated by Claude Code |
Fixes #19784
Clause-②: no (narrowing)
Implements follow-up C of ruling
5690859601(on #18239, batch #139 item 2, letter B, maintainer 「同意」): 「A composed artifact is complete or it is refused」.composeStacksstep 3 (theconcatpass) now refuses a stack whose value for a concatenated collection key is not an array. It uses the envelope step 2 already raises for a non-arrayobjects(landed in #19783). Before this change the pass kept only the array values, printed a one-timeconsole.warn, and composed an artifact without that stack's grants, seed rows, views and so on.The measurement, per key (the card asked for each key to be re-measured before widening)
Probe at base
3f9e2eaa1c, before the fix: stack A declares the key as[{ name: 'a_item' }]. Stack B is hand-built and declares the same key as a map{ b_item: { name: 'b_item' } }. The probe composes[A, B]and reads whether B's content reaches the composed artifact. It ran undermanifest: 'last'and again undermanifest: 'preserve'.datasources,datasourceMapping,translations,objectExtensions,apps,views,viewItems,pages,dashboards,reports,datasets,actions,flows,jobs,emailTemplates,docs,books,positions,permissions,capabilities,sharingRules,apis,webhooks,agents,tools,skills,hooks,mappings,analyticsCubes,connectors,data,requires,tiers(33)last: absent.preserve: present only inside B's package body, carrying the malformed value, so the top level and the package list disagreepackages,plugins,devPlugins,devLogins(4, excluded from the package body)All 37
concatkeys read YES and none reads ambiguous, so the refusal covers every one. The key list is derived fromCOMPOSE_KEY_DISPOSITIONSin the code and in the test, never transcribed, so a key added to the table is covered the day it lands. A non-object entry inside an array is out of scope, as #18239 already measured: it is concatenated as-is and the content is unchanged. The new test pins this.What changed (
packages/spec/src/stack.zod.ts)undefinedmeans the key is absent and the stack is skipped. Any other non-array value (map, number, string,null,false,Set) throwsStackSchemaInvalidErrorwithcode: 'STACK_SCHEMA_INVALID'andstatus: 422. Itsissuescarries one zod issue withpathrooted at the key,code: 'invalid_type'andexpected: 'array'. The message startscomposeStacks validation failed:, names the stack by manifest id and position, and names the key. For a keydefineStackaccepts in the map form (MAP_SUPPORTED_FIELDS), the message adds the parenthetical theobjectsrefusal carries. No new error code and no new export: the code the strict parse raises for the same authored mistake is reused, soClause-②: noholds.objectsrefusal moved intodescribeNonArrayCollection(key, value), and both raise sites use it. Theobjectsmessage is byte-identical, andcompose-stacks-objects-shape-refusal.test.tsis green unchanged.collectSeedDataObjectErrorsandcollectPermissionGrantObjectErrorswarned and returned on a non-arraydataorpermissions. After this change that branch has no reachable caller.defineStackcalls the collectors only after the strict parse, which rejects the shape, andcomposeStackscalls them in step 3b, after step 3 has refused the shape. The guards stay as silent type guards, so each rule never depends on its caller's order, and the docblocks now say that. The warn calls are gone.warnMalformedCollectionKeybecomeswarnMalformedCollectionEntry. With both of its'value'callers gone, the non-array-value sentence was dead code. The helper now carries only the entry notice (its one caller ismergeObjects, for a non-objectobjectsentry), deduplicated per key, and its printed text is unchanged.Hunks stay out of
mergeActionsIntoObjects(the sibling #19785 edit) and out ofpreservePackageEntriesand the options schema (open PR #19666). The one step-3a comment line touched says "refusal" where it said "warning".origin/main628e55dfa6was merged before opening; no conflicts.Fixture triage (the rule's consumer radius)
compose-stacks-key-loss.test.ts: "warns rather than skipping a collection key that holds a non-array value" pinned exactly the warn-and-drop branch this removes. The case is replaced in place and now asserts the refusal (code+status+ the key in the message).stack-artifact-crossref.test.ts: the two cases "a non-arraypermissions/datacomposes, and the key is warned about exactly once" are replaced with refusal assertions (code+status), and the block header is rewritten. The entry-shape cases in the same block are unchanged and green.test-typecheck-debt.json: re-recorded, because the replaced key-loss case dropped two implicit-any callback parameters (debt 4 to 3 and 3 to 2, shrink only).packages/spec, no test relies on the skip-and-warn text (a grep for the warning's phrasing acrosspackages/**tests found none). The public face is byte-unchanged (check:api-surfacegreen), so no importer owes a test.Tests and evidence (at
0bf2e55646)packages/spec/src/compose-stacks-concat-shape-refusal.test.tshas 125 cases: per key, the refusal (map value), the refusal underpreserve, and the array control that composes both stacks' entries. Onpermissionsanddatait also covers number, string,null,falseandSet, in both positions. It adds the absent-key control, the entry-carried control and the strict-door same-code pin.node scripts/ablation-replace.mjs: the refusal was replaced bycontinue(the anchor hit 1 time and went 1 to 0, the marker went 0 to 1, blob9b46ea3999bbtoed55f104c3e1). Result:Tests 84 failed | 41 passed (125), which is 37 keys times 2 refusal cases plus 10 shape rows red, with all 41 controls green. Restore: blob equal to HEAD andgit diff HEADempty. The test imports./stack.zodfromsrc, so there was nodistleg.pnpm --filter @objectstack/spec test:Test Files 518 passed (518),Tests 15213 passed | 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0, after the debt re-record.pnpm --filter @objectstack/spec check:generated: "All 15 generated artifacts are up to date", with dist built from this tree.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran: "83 derived famil(ies) accounted for — 80 run, 3 NOT-MEASURED". All 80 that ran exit 0.check:dual-build-cjs-loads, reason: needs a fullpnpm build, and that prerequisite was not met here.check:type-check-debt, reason: needs the full./packages/*build closure, and that prerequisite was not met here.check:lean-entry-closure, reason: needs a built@objectstack/objectql; the build hit a lock queue-timeout (99) behind a long-running holder. All three are left to CI.Acceptance notes
normalizeMetadataCollection) reads any object throughObject.entries. As a result, strictdefineStack({ …, permissions: new Set([{…}]) })(or aMap) is accepted withpermissionsequal to[], and the grants are silently gone before the parse ever sees them. It is reported to the seat for filing and not touched here: it sits upstream of composition and is a different seam.nullandfalsecarry no content, but they are refused for parity with theobjectsarm (undefinedalone means absent). The strict parse rejects them too.Generated by Claude Code