mvanhorn/last30days-skill · error · ValueError
top-level plan must be an object
Error message
top-level plan must be an object
What it means
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.
Source
Thrown at skills/last30days/scripts/lib/planner.py:170
"trustpilot": {"reference", "company_signal", "social"},
"amazon": {"reference", "company_signal", "product_signal"},
"xiaohongshu": {"video", "video_shortform", "social"},
"github": {"discussion", "link"},
"grounding": {"web", "reference", "link"},
"perplexity": {"web", "reference", "analysis"},
"jobs": {"jobs", "company_signal", "link"},
"corpus": {"reference", "analysis"},
}
def validate_external_plan(raw: dict) -> None:
"""Validate explicit-plan structure before permissive sanitization.
Enum-like metadata stays permissive because direct pipeline callers rely on
the sanitizer to canonicalize those values.
"""
if not isinstance(raw, dict):
raise ValueError("top-level plan must be an object")
for field in ("intent", "freshness_mode", "cluster_mode", "subqueries"):
if field not in raw:
raise ValueError(f"missing required field '{field}'")
for field in ("intent", "freshness_mode", "cluster_mode"):
if not isinstance(raw[field], str) or not raw[field].strip():
raise ValueError(f"field '{field}' must be a non-empty string")
source_weights = raw.get("source_weights")
if source_weights is not None and not isinstance(source_weights, dict):
raise ValueError("field 'source_weights' must be an object when provided")
for source, weight in (source_weights or {}).items():
if (
not isinstance(source, str)
or not source.strip()
or isinstance(weight, bool)
or not isinstance(weight, (int, float))
):
raise ValueError("field 'source_weights' must map source names to numbers")View on GitHub (pinned to c7460f6114)
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'>.
Example fix
# before (double-encoded) plan = json.loads(json.dumps(plan_obj)) # somewhere the dict became a string run_pipeline(topic, external_plan=plan) # after assert isinstance(plan, dict) run_pipeline(topic, external_plan=plan)
Defensive patterns
Strategy: type-guard
Validate before calling
import json
if isinstance(raw_plan, str):
raw_plan = json.loads(raw_plan) # decode exactly once
assert isinstance(raw_plan, dict), f"plan must be dict, got {type(raw_plan).__name__}" Type guard
def is_plan_object(v) -> bool:
return isinstance(v, dict) Try / catch
try:
planner.validate_external_plan(raw)
except ValueError as exc:
raise PlanInputError(f"external plan rejected: {exc}") from exc Prevention
- 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).
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- missing required field '{field}'
- field '{field}' must be a non-empty string
- field 'source_weights' must be an object when provided
- field 'source_weights' must map source names to numbers
- field 'subqueries' must be a non-empty array
AI-assisted analysis of mvanhorn/last30days-skill@c7460f6114 (2026-08-15).
Data as JSON: /api/errors/1d6df6eda561f684.
Report an issue: GitHub.