{"record":{"id":"04b79c6e752fa31e","repo":"puppetlabs/puppet","slug":"facts-must-be-a-hash-not-a-facts-class","errorCode":null,"errorMessage":"Facts must be a Hash not a #{facts.class}","messagePattern":"Facts must be a Hash not a #(.+?)","errorType":"validation","errorClass":"ArgumentError","httpStatus":null,"severity":"error","filePath":"lib/puppet/http/service/compiler.rb","lineNumber":168,"sourceCode":"  # @param [String] job_id The id of the orchestrator job that triggered this run.\n  # @param [Hash] options A hash of options beyond direct input to catalogs. Options:\n  #    - prefer_requested_environment Whether to always override a node's classified\n  #      environment with the one supplied in the request. If this is true and no environment\n  #      is supplied, fall back to the classified environment, or finally, 'production'.\n  #    - capture_logs Whether to return the errors and warnings that occurred during\n  #      compilation alongside the catalog in the response body.\n  #    - log_level The logging level to use during the compile when capture_logs is true.\n  #      Options are 'err', 'warning', 'info', and 'debug'.\n  #\n  # @return [Array<Puppet::HTTP::Response, Puppet::Resource::Catalog, Array<String>>] An array\n  #   containing the request response, the deserialized catalog returned by\n  #   the server and array containing logs (log array will be empty if capture_logs is false)\n  #\n  def post_catalog4(certname, persistence:, environment:, facts: nil, trusted_facts: nil, transaction_uuid: nil, job_id: nil, options: nil)\n    unless persistence.is_a?(Hash) && (missing = [:facts, :catalog] - persistence.keys.map(&:to_sym)).empty?\n      raise ArgumentError, \"The 'persistence' hash is missing the keys: #{missing.join(', ')}\"\n    end\n    raise ArgumentError, \"Facts must be a Hash not a #{facts.class}\" unless facts.nil? || facts.is_a?(Hash)\n\n    body = {\n      certname: certname,\n      persistence: persistence,\n      environment: environment,\n      transaction_uuid: transaction_uuid,\n      job_id: job_id,\n      options: options\n    }\n    body[:facts] = { values: facts } unless facts.nil?\n    body[:trusted_facts] = { values: trusted_facts } unless trusted_facts.nil?\n    headers = add_puppet_headers(\n      'Accept' => get_mime_types(Puppet::Resource::Catalog).join(', '),\n      'Content-Type' => 'application/json'\n    )\n\n    url = URI::HTTPS.build(host: @url.host, port: @url.port, path: Puppet::Util.uri_encode(\"/puppet/v4/catalog\"))\n    response = @client.post(","sourceCodeStart":150,"sourceCodeEnd":186,"githubUrl":"https://github.com/puppetlabs/puppet/blob/e227c27540975c25aa22d533a52424a9d2fc886a/lib/puppet/http/service/compiler.rb#L150-L186","documentation":"post_catalog4 only accepts facts: as nil (omitted) or a Hash of fact name to value; anything else is rejected with ArgumentError before the request body is built. The facts hash is wrapped as body[:facts] = { values: facts } for the v4 wire format, so a non-Hash cannot be serialized.","triggerScenarios":"Calling api.post_catalog4(..., facts: some_facts_object) with a Puppet::Node::Facts instance instead of its .values hash; facts: as a raw JSON String; facts: as an Array of pairs.","commonSituations":"Reusing a Puppet::Node::Facts object from the indirector where the API wants the raw values hash; passing a serialized JSON string instead of the parsed hash; custom test fixtures using Arrays or OpenStructs.","solutions":["Pass facts.values when you hold a Puppet::Node::Facts object","Ensure the facts came from JSON.parse or YAML.safe_load, not a raw String","Omit facts: entirely when you do not want to send facts"],"exampleFix":"# before (ruby)\napi.post_catalog4(certname, persistence: p, environment: env,\n  facts: node_facts)            # Puppet::Node::Facts => ArgumentError\n\n# after\napi.post_catalog4(certname, persistence: p, environment: env,\n  facts: node_facts.values)     # Hash","handlingStrategy":"type-guard","validationCode":"# ruby\nfacts = facts.values if facts.is_a?(Puppet::Node::Facts)\nraise ArgumentError, 'facts must be a Hash' unless facts.nil? || facts.is_a?(Hash)","typeGuard":"def facts_hash?(f)\n  f.nil? || f.is_a?(Hash)\nend","tryCatchPattern":"begin\n  api.post_catalog4(certname, persistence: p, environment: env, facts: facts)\nrescue ArgumentError => e\n  raise unless e.message.include?('Facts must be a Hash')\n  facts = facts.values if facts.respond_to?(:values)\n  retry\nend","preventionTips":["Unwrap Puppet::Node::Facts with .values at the boundary of your code","Parse serialized facts once (JSON.parse) and pass the resulting Hash, never a String","Assert facts: is a Hash in specs for every call site"],"tags":["puppet-http","catalog-api","type-validation","facts"],"backgroundTag":"argument-type-mismatch","analyzedSha":"e227c27540975c25aa22d533a52424a9d2fc886a","analyzedAt":"2026-08-21T20:49:46.650Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}