{"record":{"id":"1d6df6eda561f684","repo":"mvanhorn/last30days-skill","slug":"top-level-plan-must-be-an-object","errorCode":null,"errorMessage":"top-level plan must be an object","messagePattern":"top-level plan must be an object","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"skills/last30days/scripts/lib/planner.py","lineNumber":170,"sourceCode":"    \"trustpilot\": {\"reference\", \"company_signal\", \"social\"},\n    \"amazon\": {\"reference\", \"company_signal\", \"product_signal\"},\n    \"xiaohongshu\": {\"video\", \"video_shortform\", \"social\"},\n    \"github\": {\"discussion\", \"link\"},\n    \"grounding\": {\"web\", \"reference\", \"link\"},\n    \"perplexity\": {\"web\", \"reference\", \"analysis\"},\n    \"jobs\": {\"jobs\", \"company_signal\", \"link\"},\n    \"corpus\": {\"reference\", \"analysis\"},\n}\n\n\ndef validate_external_plan(raw: dict) -> None:\n    \"\"\"Validate explicit-plan structure before permissive sanitization.\n\n    Enum-like metadata stays permissive because direct pipeline callers rely on\n    the sanitizer to canonicalize those values.\n    \"\"\"\n    if not isinstance(raw, dict):\n        raise ValueError(\"top-level plan must be an object\")\n    for field in (\"intent\", \"freshness_mode\", \"cluster_mode\", \"subqueries\"):\n        if field not in raw:\n            raise ValueError(f\"missing required field '{field}'\")\n    for field in (\"intent\", \"freshness_mode\", \"cluster_mode\"):\n        if not isinstance(raw[field], str) or not raw[field].strip():\n            raise ValueError(f\"field '{field}' must be a non-empty string\")\n\n    source_weights = raw.get(\"source_weights\")\n    if source_weights is not None and not isinstance(source_weights, dict):\n        raise ValueError(\"field 'source_weights' must be an object when provided\")\n    for source, weight in (source_weights or {}).items():\n        if (\n            not isinstance(source, str)\n            or not source.strip()\n            or isinstance(weight, bool)\n            or not isinstance(weight, (int, float))\n        ):\n            raise ValueError(\"field 'source_weights' must map source names to numbers\")","sourceCodeStart":152,"sourceCodeEnd":188,"githubUrl":"https://github.com/mvanhorn/last30days-skill/blob/c7460f6114449ddfe6ea3fc2f23c3d910c0e740c/skills/last30days/scripts/lib/planner.py#L152-L188","documentation":"First check in planner.validate_external_plan: the raw plan (e.g. JSON passed via the --plan flag from Claude Code or another harness) must be a JSON object/dict at the top level. Arrays, strings, numbers, or null all fail here. This validation runs BEFORE the permissive sanitizer, because an explicit plan is a contract — structural problems must fail loudly rather than be silently repaired.","triggerScenarios":"Passing external_plan as a JSON array of subqueries, a JSON string containing serialized JSON (double-encoded), or None/null. validate_external_plan(raw) is called from run_pipeline when external_plan is not None.","commonSituations":"Double-encoded JSON (json.dumps applied twice); an agent emitting a list instead of an object; YAML-parsed plans that came back as None; hand-written plan files whose top level is a bare array.","solutions":["Ensure the plan is a single JSON object with intent, freshness_mode, cluster_mode, subqueries keys.","If the payload is a string, json.loads it exactly once before passing.","Validate the file with python -c \"import json;print(type(json.load(open('plan.json'))))\" — it must print <class 'dict'>."],"exampleFix":"# before (double-encoded)\nplan = json.loads(json.dumps(plan_obj))  # somewhere the dict became a string\nrun_pipeline(topic, external_plan=plan)\n\n# after\nassert isinstance(plan, dict)\nrun_pipeline(topic, external_plan=plan)","handlingStrategy":"type-guard","validationCode":"import json\nif isinstance(raw_plan, str):\n    raw_plan = json.loads(raw_plan)  # decode exactly once\nassert isinstance(raw_plan, dict), f\"plan must be dict, got {type(raw_plan).__name__}\"","typeGuard":"def is_plan_object(v) -> bool:\n    return isinstance(v, dict)","tryCatchPattern":"try:\n    planner.validate_external_plan(raw)\nexcept ValueError as exc:\n    raise PlanInputError(f\"external plan rejected: {exc}\") from exc","preventionTips":["Run plans through json.loads exactly once; double-encoded JSON is the most common cause.","Assert type(plan) is dict before calling run_pipeline(external_plan=plan)."],"tags":["planner","external-plan","validation","json"],"backgroundTag":null,"analyzedSha":"c7460f6114449ddfe6ea3fc2f23c3d910c0e740c","analyzedAt":"2026-08-15T03:34:49.540Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}